Understanding the Problem
In this phase, we need to set up the backend of our authentication application. This includes creating the project structure, installing dependencies, setting up Sequelize, initializing Express, and configuring security middlewares.
The goal is to have a well-structured Express application ready for implementing authentication features in later phases.
Planning the Solution
- Create the project folder structure
- Set up the README.md file
- Create a .gitignore file
- Initialize Git repository
- Create backend and frontend folders
- Install necessary backend dependencies
- Set up environment variables
- Configure Sequelize
- Set up Express application with security middleware
- Create routes
- Configure the server
- Set up User model, migration, and seeder
- Test the server
Implementing the Solution
1. Create Project Folder Structure
Start by creating a root folder for your project. The name should be meaningful and represent your project.
mkdir my_authentication_project
cd my_authentication_project
Why this matters: Good project structure is the foundation of maintainable code. Separating concerns through folder structure makes the application easier to navigate and modify.
2. Set up README.md
Create a README.md file at the root of your project to document your API and database schema.
touch README.md
Add the following content to README.md:
# My Authentication Project
## Database Schema Design

## API Documentation
### All endpoints that require authentication
All endpoints that require a current user to be logged in.
* Request: endpoints that require authentication
* Error Response: Require authentication
* Status Code: 401
* Headers:
* Content-Type: application/json
* Body:
```json
{
"message": "Authentication required"
}
```
Documentation purpose: The README provides essential information about your API, making it easier for other developers (or your future self) to understand how to use it. Good documentation is a mark of professional software development.
3. Create .gitignore File
Create a .gitignore file at the root to exclude certain files from version control:
touch .gitignore
Add the following content to .gitignore:
node_modules
.env
build
.DS_Store
*.db
Why we exclude these files:
node_modules: Contains all dependencies, often very large and should be installed by npm instead of tracked in git.env: Contains sensitive information like API keys and passwords that shouldn't be publicbuild: Contains compiled code that's generated from source files.DS_Store: macOS system files that aren't part of the project*.db: Database files that could be large and contain sensitive data
4. Initialize Git Repository
Configure Git to use 'main' as the default branch, initialize the repository, and make your first commit:
git config --global init.defaultBranch main
git init
git add .
git commit -m 'Initial commit'
Create a remote GitHub repository and connect it to your local repository:
git remote add origin <github-remote-url>
git push origin main
Version control benefits: Git allows you to track changes, revert to previous versions if needed, and collaborate with others. Using main as the default branch name follows current best practices.
5. Create Backend and Frontend Folders
Create folders to separate backend and frontend code:
mkdir backend frontend images
Separation of concerns: Keeping frontend and backend code separate helps maintain a clear architecture where each part has distinct responsibilities. The images folder will store assets like database schema diagrams.
6. Install Backend Dependencies
Initialize the package.json file in the backend folder and install necessary dependencies:
cd backend
npm init -y
npm install cookie-parser cors csurf dotenv express express-async-errors helmet jsonwebtoken morgan per-env sequelize@6 sequelize-cli@6 pg
npm install -D sqlite3 dotenv-cli nodemon
Dependencies explained:
cookie-parser: Parses cookies from HTTP requestscors: Enables Cross-Origin Resource Sharing for API accesscsurf: Provides protection against Cross-Site Request Forgery attacksdotenv: Loads environment variables from .env filesexpress: Web framework for Node.jsexpress-async-errors: Automatically catches errors in async/await functionshelmet: Sets HTTP headers for securityjsonwebtoken: Implements JWT for secure authenticationmorgan: HTTP request logger middlewareper-env: Runs different scripts based on the environmentsequelize: ORM for database operationspg: PostgreSQL driver for production usesqlite3: Lightweight database for development (dev dependency)nodemon: Auto-restarts server during development (dev dependency)
7. Set Up Environment Variables
Create a .env file in the backend folder:
touch .env
Add the following content to the .env file:
PORT=8000
DB_FILE=db/dev.db
JWT_SECRET=your_secret_here
JWT_EXPIRES_IN=604800
SCHEMA=your_schema_name_here
To generate a strong JWT secret, you can use the openssl command:
openssl rand -base64 10
Environment variable purposes:
PORT: The network port the server will listen onDB_FILE: Location of the SQLite database file for developmentJWT_SECRET: Secret key used to sign JWTs (should be long and random)JWT_EXPIRES_IN: Token expiration time in seconds (604800 = 1 week)SCHEMA: Database schema name (used for PostgreSQL in production)
Using environment variables keeps sensitive information out of your code and allows for different configurations in different environments.
8. Create Configuration Files
Create a config folder in the backend folder and add an index.js file for environment variables:
mkdir -p backend/config
touch backend/config/index.js
Add the following content to config/index.js:
// backend/config/index.js
module.exports = {
environment: process.env.NODE_ENV || 'development', // Default to development if not specified
port: process.env.PORT || 8000, // Server port, fallback to 8000
dbFile: process.env.DB_FILE, // Database file location
jwtConfig: {
secret: process.env.JWT_SECRET, // Secret key for JWT signing
expiresIn: process.env.JWT_EXPIRES_IN // JWT expiration time
}
};
Configuration centralization: This file acts as a centralized place to access environment variables throughout the application. Using defaults (like || 'development') provides fallbacks if variables aren't set.
9. Set Up Sequelize
Create a .sequelizerc file in the backend folder:
touch backend/.sequelizerc
Add the following content to .sequelizerc:
// backend/.sequelizerc
const path = require('path');
module.exports = {
config: path.resolve('config', 'database.js'), // Database configuration path
'models-path': path.resolve('db', 'models'), // Models directory path
'seeders-path': path.resolve('db', 'seeders'), // Seed data directory path
'migrations-path': path.resolve('db', 'migrations') // Migrations directory path
};
Initialize Sequelize:
npx sequelize init
Update the config/database.js file with the following content:
// backend/config/database.js
const config = require('./index');
module.exports = {
development: {
storage: config.dbFile, // SQLite file location
dialect: "sqlite", // Use SQLite in development
seederStorage: "sequelize", // Store seeder execution history in the database
logQueryParameters: true, // Log query parameters for debugging
typeValidation: true // Enable strict type validation
},
production: {
use_env_variable: 'DATABASE_URL', // Use DATABASE_URL from environment
dialect: 'postgres', // Use PostgreSQL in production
seederStorage: 'sequelize', // Store seeder execution history
dialectOptions: {
ssl: {
require: true, // Require SSL for security
rejectUnauthorized: false // Allow self-signed certificates
}
},
define: {
schema: process.env.SCHEMA // Set schema for PostgreSQL
}
}
};
Create a psql-setup-script.js file in the backend folder:
touch backend/psql-setup-script.js
Add the following content to psql-setup-script.js:
// backend/psql-setup-script.js
const { sequelize } = require('./db/models');
sequelize.showAllSchemas({ logging: false }).then(async (data) => {
if (!data.includes(process.env.SCHEMA)) {
await sequelize.createSchema(process.env.SCHEMA); // Create schema if it doesn't exist
}
});
Database configuration strategy: This setup provides different database configurations for development and production:
- Development: Uses SQLite (file-based database) for simplicity and ease of setup
- Production: Uses PostgreSQL with SSL for security and better performance at scale
The psql-setup-script.js is used during deployment to ensure the PostgreSQL schema exists before migrations run.
10. Set Up Express Application
Create an app.js file in the backend folder:
touch backend/app.js
Add the following content to app.js:
// backend/app.js
const express = require('express');
require('express-async-errors'); // Automatically catch async errors
const morgan = require('morgan'); // HTTP request logger
const cors = require('cors'); // Cross-Origin Resource Sharing
const csurf = require('csurf'); // CSRF protection
const helmet = require('helmet'); // Security headers
const cookieParser = require('cookie-parser'); // Parse cookies in requests
const { environment } = require('./config');
const isProduction = environment === 'production';
const app = express();
// Connect morgan middleware for logging HTTP requests
app.use(morgan('dev'));
// Parse cookies and JSON bodies
app.use(cookieParser());
app.use(express.json());
// Security middleware
if (!isProduction) {
// Enable CORS only in development
// In production, frontend and backend should be on same domain
app.use(cors());
}
// Helmet helps set security headers
app.use(
helmet.crossOriginResourcePolicy({
policy: "cross-origin" // Allow resources to be shared cross-origin
})
);
// Set the _csrf token and create req.csrfToken method
app.use(
csurf({
cookie: {
secure: isProduction, // HTTPS only in production
sameSite: isProduction && "Lax", // Controls when cookies are sent with cross-site requests
httpOnly: true // Prevents JavaScript access to cookies
}
})
);
// Connect routes
const routes = require('./routes');
app.use(routes);
module.exports = app;
Express middleware stack: This configuration sets up a secure Express application with several layers of middleware:
- morgan: Logs incoming HTTP requests for debugging
- cookieParser: Parses Cookie header and populates req.cookies
- express.json: Parses JSON request bodies
- cors: Allows cross-origin requests in development
- helmet: Sets HTTP headers to protect against common web vulnerabilities
- csurf: Protects against CSRF attacks by requiring a token for non-GET requests
The middleware order is important - each processes the request before passing it to the next.
11. Create Routes
Set up the routes folder and create an index.js file:
mkdir -p backend/routes
touch backend/routes/index.js
Add the following content to routes/index.js:
// backend/routes/index.js
const express = require('express');
const router = express.Router();
// Test route - serves as a health check and sets CSRF token
router.get('/hello/world', function(req, res) {
res.cookie('XSRF-TOKEN', req.csrfToken()); // Set CSRF token in cookie for frontend
res.send('Hello World!'); // Simple response to confirm server works
});
// CSRF token restoration route
router.get("/api/csrf/restore", (req, res) => {
const csrfToken = req.csrfToken();
res.cookie("XSRF-TOKEN", csrfToken); // Set token in cookie
res.status(200).json({
'XSRF-Token': csrfToken // Also return token in JSON response
});
});
module.exports = router;
CSRF protection explained: Cross-Site Request Forgery (CSRF) is an attack that tricks users into submitting requests they didn't intend. Our protection works in two parts:
- The server sends a unique token to the client (in the XSRF-TOKEN cookie)
- The client must include this token in the header of all non-GET requests
The /api/csrf/restore route allows the frontend to get a fresh token if needed.
12. Set Up User Model and Migration
Create a User model and migration:
npx sequelize model:generate --name User --attributes username:string,email:string,hashedPassword:string
This command creates:
- A User model file in db/models/user.js
- A migration file in db/migrations/ with a timestamp
Update the User model in db/models/user.js:
// backend/db/models/user.js
'use strict';
const { Model, Validator } = require('sequelize');
const bcrypt = require('bcryptjs');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
toSafeObject() {
const { id, username, email } = this; // context will be the User instance
return { id, username, email };
}
validatePassword(password) {
return bcrypt.compareSync(password, this.hashedPassword.toString());
}
static getCurrentUserById(id) {
return User.scope("currentUser").findByPk(id);
}
static async login({ credential, password }) {
const { Op } = require('sequelize');
const user = await User.scope('loginUser').findOne({
where: {
[Op.or]: {
username: credential,
email: credential
}
}
});
if (user && user.validatePassword(password)) {
return await User.scope('currentUser').findByPk(user.id);
}
}
static async signup({ username, email, password }) {
const hashedPassword = bcrypt.hashSync(password);
const user = await User.create({
username,
email,
hashedPassword
});
return await User.scope('currentUser').findByPk(user.id);
}
static associate(models) {
// define association here
}
}
User.init({
username: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [4, 30],
isNotEmail(value) {
if (Validator.isEmail(value)) {
throw new Error("Cannot be an email.");
}
}
}
},
email: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [3, 256],
isEmail: true
}
},
hashedPassword: {
type: DataTypes.STRING.BINARY,
allowNull: false,
validate: {
len: [60, 60]
}
}
}, {
sequelize,
modelName: 'User',
defaultScope: {
attributes: {
exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt']
}
},
scopes: {
currentUser: {
attributes: { exclude: ['hashedPassword'] }
},
loginUser: {
attributes: {}
}
}
});
return User;
};
Update the migration file in db/migrations/XXXXXXXXXXXXXX-create-user.js:
// backend/db/migrations/XXXXXXXXXXXXXX-create-user.js
'use strict';
let options = {};
if (process.env.NODE_ENV === 'production') {
options.schema = process.env.SCHEMA; // define your schema in options object
}
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.createTable('Users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
username: {
type: Sequelize.STRING(30),
allowNull: false,
unique: true
},
email: {
type: Sequelize.STRING(256),
allowNull: false,
unique: true
},
hashedPassword: {
type: Sequelize.STRING.BINARY,
allowNull: false
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
}, options);
},
down: async (queryInterface, Sequelize) => {
options.tableName = "Users";
return queryInterface.dropTable(options);
}
};
Install bcryptjs for password hashing:
npm install bcryptjs
User model security features:
- Password hashing: Passwords are hashed using bcrypt before storage
- Validation: Username and email have validation rules
- Scopes: Different scopes control which attributes are visible
- Safe objects: The toSafeObject method returns only non-sensitive data
These features work together to ensure proper security for user authentication.
13. Create User Seeds
Create a seed file for adding demo users:
npx sequelize seed:generate --name demo-user
Update the seed file in db/seeders/XXXXXXXXXXXXXX-demo-user.js:
// backend/db/seeders/XXXXXXXXXXXXXX-demo-user.js
'use strict';
const bcrypt = require('bcryptjs');
let options = {};
if (process.env.NODE_ENV === 'production') {
options.schema = process.env.SCHEMA; // define your schema in options object
}
module.exports = {
up: async (queryInterface, Sequelize) => {
options.tableName = 'Users';
return queryInterface.bulkInsert(options, [
{
email: 'demo@user.io',
username: 'Demo-lition',
hashedPassword: bcrypt.hashSync('password')
},
{
email: 'user1@user.io',
username: 'FakeUser1',
hashedPassword: bcrypt.hashSync('password2')
},
{
email: 'user2@user.io',
username: 'FakeUser2',
hashedPassword: bcrypt.hashSync('password3')
}
], {});
},
down: async (queryInterface, Sequelize) => {
options.tableName = 'Users';
const Op = Sequelize.Op;
return queryInterface.bulkDelete(options, {
username: { [Op.in]: ['Demo-lition', 'FakeUser1', 'FakeUser2'] }
}, {});
}
};
Seed data purpose: Seed data provides initial data for development and testing. These demo users allow you to test authentication features without having to create new users manually each time.
14. Run Migrations and Seeds
Run the migration to create the Users table:
npx dotenv sequelize db:migrate
Run the seed to add demo users:
npx dotenv sequelize db:seed:all
Database initialization: These commands initialize your database with:
- The table structure defined in your migrations
- Initial data defined in your seeders
The dotenv prefix ensures environment variables from your .env file are loaded.
15. Add Authentication Middleware
Create a utils folder in the backend directory for middleware functions:
mkdir -p backend/utils
Create the auth.js file in the utils folder:
touch backend/utils/auth.js
Add the following content to auth.js:
// backend/utils/auth.js
const jwt = require('jsonwebtoken');
const { jwtConfig } = require('../config');
const { User } = require('../db/models');
const { secret, expiresIn } = jwtConfig;
// Send a JWT Cookie
const setTokenCookie = (res, user) => {
// Create the token
const token = jwt.sign(
{ data: user.toSafeObject() },
secret,
{ expiresIn: parseInt(expiresIn) } // 604,800 seconds = 1 week
);
const isProduction = process.env.NODE_ENV === "production";
// Set the token cookie
res.cookie('token', token, {
maxAge: expiresIn * 1000, // maxAge in milliseconds
httpOnly: true,
secure: isProduction,
sameSite: isProduction && "Lax"
});
return token;
};
// Restore the user from a JWT token
const restoreUser = (req, res, next) => {
// token parsed from cookies
const { token } = req.cookies;
req.user = null;
return jwt.verify(token, secret, null, async (err, jwtPayload) => {
if (err) {
return next();
}
try {
const { id } = jwtPayload.data;
req.user = await User.getCurrentUserById(id);
} catch (e) {
res.clearCookie('token');
return next();
}
if (!req.user) res.clearCookie('token');
return next();
});
};
// Require user to be authenticated
const requireAuth = function (req, _res, next) {
if (req.user) return next();
const err = new Error('Authentication required');
err.title = 'Authentication required';
err.errors = { message: 'Authentication required' };
err.status = 401;
return next(err);
};
module.exports = { setTokenCookie, restoreUser, requireAuth };
Authentication middleware explanation:
- setTokenCookie: Creates a JWT and sets it in an HTTP-only cookie
- restoreUser: Verifies the JWT and loads the user from the database
- requireAuth: Used to protect routes that require authentication
These middleware functions work together to implement JWT-based authentication.
16. Set Up Server
Create a bin folder and www file to start the server:
mkdir -p backend/bin
touch backend/bin/www
Add the following content to bin/www:
#!/usr/bin/env node
// backend/bin/www
// Import environment variables
require('dotenv').config(); // Load variables from .env file
const { port } = require('../config');
const app = require('../app');
const db = require('../db/models');
// Check the database connection before starting the app
db.sequelize
.authenticate() // Test database connection
.then(() => {
console.log('Database connection success! Sequelize is ready to use...');
// Start listening for connections
app.listen(port, () => console.log(`Listening on port ${port}...`));
})
.catch((err) => {
console.log('Database connection failure.');
console.error(err);
});
Server startup sequence: This file follows a best practice of checking for database connectivity before starting the server. This prevents the server from starting if the database is unavailable, which would lead to runtime errors later.
The #!/usr/bin/env node shebang allows the file to be run directly from the command line on Unix-like systems.
17. Configure Package.json Scripts
Update your backend/package.json file to include the following scripts:
"scripts": {
"sequelize": "sequelize", // Run Sequelize CLI directly
"sequelize-cli": "sequelize-cli", // Alternative way to run Sequelize CLI
"start": "per-env", // Runs different scripts based on NODE_ENV
"start:development": "nodemon ./bin/www", // Development: auto-restart on changes
"start:production": "node ./bin/www", // Production: regular node process
"build": "node psql-setup-script.js" // Used during deployment for database setup
}
npm scripts benefits: These scripts provide convenient shortcuts for common operations:
- Using
per-envallows different start commands based on the environment nodemonin development automatically restarts the server when files change- The
buildscript is used by deployment platforms like Heroku to set up the database
18. Test the Server
Start the server and test the hello world route:
cd backend
npm start
Open your browser and navigate to http://localhost:8000/hello/world. You should see "Hello World!" displayed, and two cookies (_csrf and XSRF-TOKEN) should be set in your browser.
Review the Solution
At this point, you have successfully set up the backend of your authentication application. Let's review what you've accomplished:
- Created a well-structured project with separate backend and frontend folders
- Set up a Git repository with proper .gitignore
- Installed and configured necessary dependencies
- Set up environment variables for different environments
- Configured Sequelize for both development and production
- Created an Express application with security middleware
- Set up basic routes and CSRF protection
- Created a User model with authentication methods
- Created and ran migrations and seeds
- Set up authentication middleware with JWT
- Created a server that checks the database connection before starting
- Added a route to restore CSRF tokens
Your application is now ready for implementing API routes, which we'll cover in Phase 1.
Common Issues and Solutions
- Database Connection Errors: Make sure your database file path is correct in the .env file and you have the right permissions.
- CSRF Errors: When making non-GET requests, make sure to include the XSRF-TOKEN in the headers.
- Port Already in Use: If port 8000 is already in use, change the PORT in your .env file.
- Migration Errors: If migrations fail, check sequelize logs and ensure your database configuration is correct.
- Model Validation Errors: If seed data doesn't match model validation constraints, seeding will fail.
- JWT Issues: Make sure your JWT_SECRET is properly set in the .env file and that cookie settings match your environment.
Security considerations: This setup implements several important security best practices:
- CSRF protection to prevent cross-site request forgery
- Secure cookie settings (httpOnly, sameSite, secure in production)
- Security headers via Helmet
- Environment separation for development vs. production
- Secret keys stored in environment variables
- Password hashing with bcrypt
- Model validation to enforce data constraints
These measures provide a solid foundation for building a secure authentication system.
Next Steps
Now that you have set up the backend of your application, you are ready to move on to Phase 1: API Routes, where you will create API endpoints for your application.
Looking ahead: In the next phase, you'll implement user authentication routes including:
- User registration (signup)
- User login
- User logout
- Get current user session
- Authentication middleware to protect routes
The foundation you've built here will make those implementations more straightforward. With the User model, migration, and seed data in place, you now have a solid base for building authentication functionality.
What we're building: A secure backend architecture that will support user authentication with protection against common security vulnerabilities like CSRF attacks.