Understanding the Problem
In this phase, we need to implement user authentication in our Express application. This involves several key components:
- Setting up a Users database table to store user information
- Creating a User model with proper validations
- Implementing password hashing for security
- Creating authentication middleware using JSON Web Tokens (JWT)
- Testing the authentication functionality
User authentication is a critical component of almost any web application. It allows us to identify users, protect sensitive routes and information, and provide personalized experiences based on user identity.
Planning the Solution
- Set up Git feature branching for this phase
- Install bcryptjs for password hashing
- Create a Users database table migration
- Create the User model with validations
- Create seed data for demo users
- Add model scoping to protect sensitive user information
- Create authentication utility functions
- Test the authentication functionality
Implementing the Solution
1. Set Up Git Feature Branch
First, let's create a new feature branch for our authentication implementation. This helps isolate the changes and makes it easier to manage the development process.
git branch dev # Create a development branch
git checkout dev # Switch to the development branch
git checkout -b auth-setup # Create and switch to a feature branch
Git Branching Strategy: This approach follows a common branching workflow:
- main - Production-ready code
- dev - Integration branch for testing features
- feature branches (like
auth-setup) - For specific features in development
This workflow allows for parallel development and isolates changes until they're ready to be merged, reducing the risk of breaking the application.
2. Install bcryptjs
We'll use bcryptjs to hash passwords securely. Install it with npm:
cd backend
npm install bcryptjs
Why bcryptjs? Bcrypt is a password-hashing function designed to be slow and computationally expensive, which makes it resistant to brute-force attacks. Key features:
- Incorporates a salt to protect against rainbow table attacks
- Adaptive - can be made slower as computing power increases
- bcryptjs is a pure JavaScript implementation (vs the C++ bcrypt)
Alternatives: Other options include Argon2 (newer, potentially more secure) and PBKDF2 (sometimes preferred in regulated environments). However, bcrypt remains a solid, widely-adopted choice.
3. Create Users Table Migration
Now, let's create a migration file for the Users table. We'll use Sequelize CLI to generate the initial files:
npx sequelize model:generate --name User --attributes username:string,email:string,hashedPassword:string
This command creates two files:
- A migration file in
backend/db/migrations - A model file in
backend/db/models
Let's modify the migration file to add constraints according to our schema:
// 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 = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('Users', {
id: {
allowNull: false, // id cannot be null
autoIncrement: true, // auto-increment the id
primaryKey: true, // define id as the primary key
type: Sequelize.INTEGER
},
username: {
type: Sequelize.STRING(30), // restrict to 30 characters
allowNull: false, // username is required
unique: true // username must be unique
},
email: {
type: Sequelize.STRING(256), // restrict to 256 characters (RFC 5321 limit)
allowNull: false, // email is required
unique: true // email must be unique
},
hashedPassword: {
type: Sequelize.STRING.BINARY, // binary string for hashed password
allowNull: false // password is required
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') // auto-set creation date
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') // auto-set update date
}
}, options);
},
async down(queryInterface, Sequelize) {
options.tableName = "Users";
return queryInterface.dropTable(options); // for undoing the migration
}
};
Notice the key elements in this migration:
- We define length constraints for username (30 characters) and email (256 characters)
- We set unique constraints for both username and email
- We use BINARY string type for the hashedPassword for better security
- We set default values for createdAt and updatedAt timestamps
- We handle the schema option for production PostgreSQL database
Now, run the migration to create the Users table:
npx dotenv sequelize db:migrate
You can verify the table was created correctly using SQLite3:
sqlite3 db/dev.db ".schema Users"
Database Migration Explained:
- Migrations are like version control for your database schema - they allow you to evolve your database over time.
- Up function defines what changes to make (creating the Users table)
- Down function defines how to reverse those changes (dropping the table)
Schema design decisions:
- username(30): 30 characters is sufficient for most usernames while keeping them readable
- email(256): Follows RFC 5321 email standard's maximum length
- STRING.BINARY for hashedPassword: Helps maintain the exact binary representation of the hash
- unique constraints: Prevent duplicate usernames or emails
- options.schema: PostgreSQL uses schemas to organize tables, important for production deployment
4. Enhance the User Model with Validations
Next, let's update the User model file to add Sequelize model-level validations:
// backend/db/models/user.js
'use strict';
const { Model, Validator } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
static associate(models) {
// define association here
}
}
User.init(
{
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
len: [4, 30], // username must be 4-30 characters
isNotEmail(value) { // custom validator
if (Validator.isEmail(value)) {
throw new Error('Cannot be an email.');
}
},
},
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
len: [3, 256], // email must be 3-256 characters
isEmail: true, // must be a valid email format
},
},
hashedPassword: {
type: DataTypes.STRING.BINARY,
allowNull: false,
validate: {
len: [60, 60], // bcrypt hashes are always 60 characters
},
},
},
{
sequelize,
modelName: 'User',
}
);
return User;
};
The model-level validations provide an additional layer of data integrity:
- Username must be between 4 and 30 characters and cannot be an email
- Email must be between 3 and 256 characters and must be a valid email format
- HashedPassword must be exactly 60 characters (the standard output length of bcrypt)
The isNotEmail validator is a custom validator that prevents users from using emails as usernames, ensuring a clear distinction between these fields.
Validation Layers: This implementation uses multiple layers of validation:
- Database constraints (in the migration): Enforces rules at the database level
- Model validations (in the model file): Enforces rules at the application level
This provides defense in depth - even if application-level validation is bypassed, the database will still enforce core constraints.
Custom validation: The isNotEmail validator demonstrates how to create custom validation rules. This specific rule ensures that usernames and emails remain distinct, which is important for:
- User interface clarity - knowing which field is which
- Security - preventing confusion between identity fields
- Data integrity - maintaining separate information
5. Create User Seeds
Let's create seed data to populate our Users table with some initial users. First, generate a seeder file:
npx sequelize seed:generate --name demo-user
Now, edit the seeder file to create demo users:
// backend/db/seeders/XXXXXXXXXXXXXX-demo-user.js
'use strict';
const { User } = require('../models');
const bcrypt = require("bcryptjs"); // Import bcrypt for password hashing
let options = {};
if (process.env.NODE_ENV === 'production') {
options.schema = process.env.SCHEMA; // Define schema for production
}
module.exports = {
async up (queryInterface, Sequelize) {
await User.bulkCreate([ // Create multiple users at once
{
email: 'demo@user.io',
username: 'Demo-lition',
hashedPassword: bcrypt.hashSync('password') // Hash password on the fly
},
{
email: 'user1@user.io',
username: 'FakeUser1',
hashedPassword: bcrypt.hashSync('password2')
},
{
email: 'user2@user.io',
username: 'FakeUser2',
hashedPassword: bcrypt.hashSync('password3')
}
], { validate: true }); // Run model validations on seed data
},
async down (queryInterface, Sequelize) {
options.tableName = 'Users';
const Op = Sequelize.Op;
return queryInterface.bulkDelete(options, {
username: { [Op.in]: ['Demo-lition', 'FakeUser1', 'FakeUser2'] } // Delete specific users
}, {});
}
};
In this seeder, we:
- Use bcrypt to hash the passwords for security
- Create three demo users with different credentials
- Include the { validate: true } option to ensure our model validations are applied during seeding
- Include logic to delete these users in the down function
Now, run the seeder to populate the database:
npx dotenv sequelize db:seed:all
Verify the users were created:
sqlite3 db/dev.db 'SELECT * FROM "Users"'
Seed Data Importance: Seed data serves several critical purposes:
- Development: Provides realistic data to work with during development
- Testing: Creates consistent test fixtures for automated and manual testing
- Demonstration: Allows you to showcase the application with pre-populated data
Password Hashing with bcrypt:
bcrypt.hashSync('password')creates a hash with a random salt- The default cost factor (rounds of hashing) is 10, which is a good balance between security and performance
- Every time you call hashSync with the same password, you get a different hash due to the random salt
- Despite different hashes, bcrypt can still verify if a password matches its hash
{ validate: true }: This option ensures that the model validations are run when creating seed data. Without this, Sequelize would skip the validation step during bulk creation, which could lead to invalid data in your development database.
6. Add Model Scopes for User Data Protection
To protect sensitive user information like hashedPassword from being exposed, let's add a defaultScope to the User model:
// backend/db/models/user.js
// ... existing code
User.init(
{
// ... existing attributes
},
{
sequelize,
modelName: 'User',
defaultScope: {
attributes: {
exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt'], // Fields to exclude by default
},
},
}
);
The defaultScope ensures that when we query for users, sensitive fields like hashedPassword are automatically excluded from the results. This is an important security measure to prevent accidental exposure of sensitive data.
Model Scoping: Scopes are predefined query constraints that can be applied to Sequelize models. They help with:
- Data protection: Automatically filtering out sensitive information
- Query reuse: Defining common query patterns once
- Code organization: Centralizing query logic
Default Scope vs. Named Scopes:
- Default scope: Automatically applied whenever you query the model
- Named scopes: Applied only when explicitly requested
We could also add named scopes for specific uses. For example:
scopes: {
// Use this when you need user data but not the password
withoutPassword: {
attributes: { exclude: ['hashedPassword'] }
},
// Use this for login/authentication
loginUser: {
attributes: {} // Include all fields
}
}
Security benefit: Scopes help prevent accidentally leaking sensitive data. Even if a developer forgets to exclude sensitive fields in a specific query, the default scope provides a safety net.
7. Create Authentication Utilities
Now, let's create utility functions for authentication. First, create a utils folder and auth.js file:
mkdir -p backend/utils
touch backend/utils/auth.js
Add the following code to auth.js:
// backend/utils/auth.js
const jwt = require('jsonwebtoken'); // For creating and verifying JWTs
const { jwtConfig } = require('../config'); // Import JWT configuration
const { User } = require('../db/models'); // Import User model
const { secret, expiresIn } = jwtConfig; // Extract JWT settings
// Sends a JWT Cookie
const setTokenCookie = (res, user) => {
// Create the token with user data
const safeUser = { // Create a safe user object without sensitive data
id: user.id,
email: user.email,
username: user.username,
};
const token = jwt.sign(
{ data: safeUser }, // Payload to encode in the JWT
secret, // Secret key for signing
{ expiresIn: parseInt(expiresIn) } // Token expiration (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, // Prevents JavaScript access
secure: isProduction, // HTTPS only in production
sameSite: isProduction && "Lax" // Cross-site cookie policy
});
return token;
};
// Middleware that restores the user from a JWT token
const restoreUser = (req, res, next) => {
// token parsed from cookies
const { token } = req.cookies;
req.user = null; // Default to no user
return jwt.verify(token, secret, null, async (err, jwtPayload) => {
if (err) {
return next(); // Continue if token invalid/missing
}
try {
const { id } = jwtPayload.data; // Extract user ID from token
req.user = await User.findByPk(id, { // Look up user in database
attributes: {
include: ['email', 'createdAt', 'updatedAt'] // Include fields normally excluded
}
});
} catch (e) {
res.clearCookie('token'); // Clear invalid token
return next();
}
if (!req.user) res.clearCookie('token'); // Clear token if user not found
return next();
});
};
// If there is no current user, return an error
const requireAuth = function (req, _res, next) {
if (req.user) return next(); // Continue if user exists
const err = new Error('Authentication required'); // Create error for unauthenticated requests
err.title = 'Authentication required';
err.errors = { message: 'Authentication required' };
err.status = 401; // 401 Unauthorized status code
return next(err); // Pass error to error handler
}
module.exports = { setTokenCookie, restoreUser, requireAuth };
This file implements three key authentication utilities:
- setTokenCookie: Creates a JWT containing the user's information (without sensitive data) and sets it as an HTTP-only cookie.
- restoreUser: Middleware that verifies the JWT in the request cookies and loads the corresponding user into req.user.
- requireAuth: Middleware that checks if a user is authenticated (req.user exists) and returns an error if not.
JWT (JSON Web Token) Overview:
- JWTs are a compact, self-contained way to securely transmit information between parties
- They contain three parts: header, payload, and signature
- The signature allows you to verify the token hasn't been tampered with
Authentication Flow:
- Login: User provides credentials → Server verifies →
setTokenCookiecreates and sends JWT - Subsequent Requests:
restoreUserreads JWT → verifies it → loads user data - Protected Endpoints:
requireAuthchecks if user exists → allows or denies access
Security Considerations:
- httpOnly: true - Prevents JavaScript from accessing the cookie, mitigating XSS attacks
- secure: isProduction - Ensures cookie is only sent over HTTPS in production
- sameSite: "Lax" - Restricts cookie transmission to same-site requests, helping prevent CSRF
- safeUser - Prevents sensitive data from being stored in the JWT
8. Test Authentication Utilities
Let's add some test routes to verify our authentication utilities are working correctly. Add these to your backend/routes/api/index.js file:
// backend/routes/api/index.js
// ... existing code
// GET /api/set-token-cookie
const { setTokenCookie } = require('../../utils/auth.js');
const { User } = require('../../db/models');
router.get('/set-token-cookie', async (_req, res) => {
const user = await User.findOne({ // Find the demo user
where: {
username: 'Demo-lition'
}
});
setTokenCookie(res, user); // Set a JWT cookie for this user
return res.json({ user: user }); // Return the user in the response
});
// GET /api/restore-user
const { restoreUser } = require('../../utils/auth.js');
router.use(restoreUser); // Apply restoreUser middleware to all routes
router.get(
'/restore-user',
(req, res) => {
return res.json(req.user); // Return the current user from the request
}
);
// GET /api/require-auth
const { requireAuth } = require('../../utils/auth.js');
router.get(
'/require-auth',
requireAuth, // Apply requireAuth middleware to this route
(req, res) => {
return res.json(req.user); // Return the authenticated user
}
);
module.exports = router;
These test routes allow us to verify each authentication utility:
/api/set-token-cookie: Sets a token cookie for the demo user/api/restore-user: Shows the current user based on the token cookie/api/require-auth: Tests the requireAuth middleware
Start your server and test each route:
npm start
- First, navigate to http://localhost:8000/api/set-token-cookie to set a token cookie for the demo user.
- Then, visit http://localhost:8000/api/restore-user to see if the user is restored from the cookie.
- Finally, visit http://localhost:8000/api/require-auth to test the requireAuth middleware. You should see the user information if authenticated, or an "Authentication required" error if not.
After testing, you can remove these test routes, but make sure to keep the router.use(restoreUser) line, as it will be needed for all API routes.
// backend/routes/api/index.js
const router = require("express").Router();
const { restoreUser } = require("../../utils/auth.js");
// Connect restoreUser middleware to the API router
// If current user session is valid, set req.user to the user in the database
// If current user session is not valid, set req.user to null
router.use(restoreUser);
module.exports = router;
Testing Authentication Flow:
These test routes allow us to verify each component of our authentication system independently:
- /api/set-token-cookie: Simulates what happens after a successful login
- /api/restore-user: Demonstrates how user identity is restored from the token
- /api/require-auth: Shows how protected routes can verify authentication
Browser Testing: When you visit these URLs in sequence:
- The first request sets a token cookie in your browser
- The second request reads that cookie and shows you the user data
- The third request verifies you're authenticated before showing the data
Next Steps: After confirming everything works, we remove the test routes but keep router.use(restoreUser) which will apply to all API routes. This ensures that req.user is always populated (or null) for every API request without having to add the middleware to each route individually.
Review the Solution
Let's review what we've accomplished in this phase:
- Created a Users database table with appropriate constraints
- Implemented a User model with comprehensive validations
- Added seed data for testing purposes
- Implemented model scoping to protect sensitive user information
- Created authentication utilities using JWT
- Successfully tested these utilities
Understanding the Authentication Flow
The authentication system we've built follows this general flow:
- User Registration: The application creates a new user with a hashed password.
- User Login: The application verifies credentials, then creates a JWT containing non-sensitive user information and sets it as an HTTP-only cookie.
- Authentication: On subsequent requests, the application reads the JWT cookie, verifies it, and loads the corresponding
- Authentication: On subsequent requests, the application reads the JWT cookie, verifies it, and loads the corresponding user.
- Protected Routes: Certain routes use the requireAuth middleware to ensure only authenticated users can access them.
- User Logout: The application clears the JWT cookie to end the session.
Detailed Authentication Flow Explained:
1. Registration:
- User submits username, email, and password
- Application validates the input data
- Password is hashed using bcrypt
- User record is created in the database with the hashed password
- JWT is created and set as a cookie
2. Login:
- User submits login credentials (email/username and password)
- Application finds the user by email/username
- bcrypt.compareSync() checks if the password matches the stored hash
- If authentication succeeds, setTokenCookie() creates a JWT and sets it as a cookie
3. Authenticated Requests:
- Browser automatically sends the cookie with each request
- restoreUser middleware verifies the JWT and loads the user
- req.user is populated with the user's information
- Protected routes use requireAuth to check if req.user exists
4. Logout:
- Application calls res.clearCookie('token') to remove the JWT cookie
- Without the cookie, subsequent requests will have req.user set to null
Security Considerations
Our implementation includes several important security practices:
- Password Hashing: We never store plain-text passwords, only bcrypt hashes.
- HTTP-Only Cookies: The JWT is stored in an HTTP-only cookie, protecting it from JavaScript access and XSS attacks.
- CSRF Protection: We use the csurf middleware to prevent CSRF attacks.
- Model Scoping: Sensitive information is automatically excluded from queries unless explicitly included.
- Validation: Comprehensive validation at both the database and model level prevents invalid data.
Security Deep Dive:
Password Security:
- bcrypt hashing is one-way (can't be reversed) and includes a salt to prevent rainbow table attacks
- Even database administrators can't see the original passwords
- If the database is compromised, passwords remain protected
Cookie Security:
- httpOnly: Prevents client-side JavaScript from accessing the cookie, mitigating XSS attacks
- secure: In production, cookies only transmit over HTTPS, preventing interception
- sameSite: Controls when cookies are sent with cross-site requests, helping prevent CSRF attacks
Information Security:
- Model scoping prevents accidentally exposing sensitive data like password hashes
- safeUser in the JWT contains only necessary, non-sensitive information
- JWT expiration limits the window of opportunity if a token is somehow compromised
CSRF Protection:
- Even with JWT authentication, CSRF attacks are still possible
- Our application protects against CSRF using the csurf middleware
- Every non-GET request requires a matching CSRF token, preventing unauthorized state-changing requests
Real-world Application
The authentication system we've built is similar to those used in real-world applications. For example, many popular web services use JWT-based authentication for stateless API authentication.
Companies like Airbnb, Uber, and countless others implement similar authentication flows, with the specifics varying based on their security requirements. For instance, some applications might use shorter JWT expiration times and implement refresh tokens for enhanced security.
JWT in Production Environments:
In high-security or high-scale production environments, JWT implementations often include additional features:
Token Management:
- Shorter token lifetimes (minutes instead of days) to reduce risk if compromised
- Refresh tokens to obtain new access tokens without re-authentication
- Token revocation mechanisms to invalidate tokens before expiration
Advanced Security:
- Token rotation - Issuing new tokens periodically during active sessions
- Fingerprinting - Including device/browser fingerprints in token verification
- Monitoring - Tracking suspicious token usage patterns
Scaling Considerations:
- Stateless verification allows for horizontal scaling without shared session stores
- Microservices can independently verify tokens without central authentication servers
- Edge validation of tokens can be performed at CDN or API gateway level
Common Issues and Solutions
- JWT Not Being Set: Check that the cookie settings (especially secure and sameSite) are appropriate for your environment.
- User Not Being Restored: Ensure that the JWT secret matches between token creation and verification, and that the User model is correctly imported.
- Require Auth Always Failing: Verify that the restoreUser middleware is correctly connected before the requireAuth middleware.
- Validation Errors During Seeding: Check that your seed data meets all model validation requirements.
Troubleshooting Authentication Issues:
JWT Not Being Set:
- Check browser developer tools (Application tab > Cookies) to see if the cookie is being set
- In development, set secure: false to allow cookies over HTTP
- Ensure sameSite is compatible with your frontend/backend setup
- Verify that setTokenCookie is being called with a valid user object
User Not Being Restored:
- Check if the JWT secret in .env matches the one used to create tokens
- Verify the token expiration hasn't passed
- Check if the user still exists in the database
- Add console.logs in restoreUser to trace the execution path
Sequelize Validation Errors:
SequelizeValidationError: notNull Violation: User.username cannot be null at InstanceValidator._validate (...)
This typically means a required field is missing or null. Ensure all required fields have values that meet validation criteria.
Next Steps
Now that we have set up the core authentication functionality, we need to create the actual API routes that users will interact with:
- Login: To authenticate users with credentials
- Logout: To end a user's session
- Signup: To create new user accounts
- Get session user: To retrieve the current authenticated user
These will be implemented in Phase 4: User Authentication Routes.
Coming Up in Phase 4:
In the next phase, we'll build the actual endpoints users will interact with:
1. Login Route (POST /api/session):
- Accept credentials (email/username and password)
- Validate credentials against the database
- Set JWT cookie and return user information on success
- Handle validation and authentication errors
2. Logout Route (DELETE /api/session):
- Clear the JWT cookie
- Return a success message
3. Signup Route (POST /api/users):
- Accept user registration data
- Validate the data and check for existing users
- Hash the password and create a new user
- Set JWT cookie and return user information
4. Get Session User (GET /api/session):
- Return the current authenticated user's information
- Return null if no user is authenticated
These routes will build on the authentication utilities we've created in this phase.
Commit Your Code
Before moving on, commit your changes to the feature branch:
git add .
git commit -m "Add Users table migration and authentication utilities"
git checkout dev
git merge auth-setup
git push origin dev
Git Workflow:
git add .- Stage all changes for commitgit commit -m "..."- Create a commit with a descriptive messagegit checkout dev- Switch back to the development branchgit merge auth-setup- Merge the feature branch into developmentgit push origin dev- Push the updated development branch to GitHub
This workflow keeps your changes organized and makes collaboration easier. In a team environment, you might also:
- Create a pull request for code review before merging
- Run automated tests to ensure nothing breaks
- Have another developer review the changes
Once the feature is thoroughly tested in the development branch, it can later be merged into the main branch for production deployment.
Authentication vs. Authorization: It's important to understand the distinction:
This phase focuses primarily on authentication - establishing and verifying user identity. Authorization (permissions) would typically be built on top of this foundation.
JWT-based Authentication: We're implementing a token-based authentication system using JWTs (JSON Web Tokens). This is a stateless approach that: