Understanding User Authentication
Authentication is the process of verifying who a user is, while authorization is the process of verifying what they have access to. In this guide, we'll focus on building a secure authentication system for our Express application.
Building a robust authentication system involves several key components:
- A database schema for storing user information
- Secure password handling
- Session management with JSON Web Tokens (JWTs)
- Authentication middleware for protecting routes
Let's approach this systematically using George Polya's problem-solving method:
Step 1: Understand the Problem
We need to create a secure user authentication system that will:
- Store user information securely in a database
- Protect passwords with strong cryptographic hashing
- Allow users to sign up, log in, and log out
- Maintain user sessions securely using JWTs
- Protect routes that require authentication
Step 2: Devise a Plan
- Set up a database schema for the Users table
- Create a User model with proper validation
- Add model scopes to protect sensitive information
- Seed the database with test users
- Create authentication utility functions
- Implement authentication middleware
- Test the authentication system
Step 3: Execute the Plan
Let's start implementing our user authentication system:
Database Schema for Users
Understanding the User Schema
The foundation of our authentication system is the Users table. This table will store essential user information, including:
- id: A unique identifier for each user
- username: A unique username for login and display
- email: A unique email address for user contact and alternative login
- hashedPassword: A cryptographically secure hashed version of the user's password
- firstName, lastName: Personal information
- createdAt, updatedAt: Timestamps for record-keeping
Let's define this schema using Sequelize, a popular ORM for Node.js:
Creating the Users Migration
First, we'll generate a migration file that will create our Users table:
// Command to generate the migration
npx sequelize model:generate --name User --attributes username:string,email:string,hashedPassword:string
This command creates two files:
- A migration file in
backend/db/migrationsthat defines the table structure - A model file in
backend/db/modelsthat defines the User class
Let's update the migration file to include all the constraints we need:
// backend/db/migrations/XXXXXX-create-user.js
'use strict';
let options = {};
if (process.env.NODE_ENV === 'production') {
options.schema = process.env.SCHEMA; // define schema in options
}
module.exports = {
async up(queryInterface, Sequelize) {
await 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
},
firstName: {
type: Sequelize.STRING,
allowNull: false
},
lastName: {
type: Sequelize.STRING,
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);
},
async down(queryInterface, Sequelize) {
options.tableName = "Users";
return queryInterface.dropTable(options);
}
};
This migration file defines the structure of our Users table with several important constraints:
- allowNull: false for required fields
- unique: true for username and email
- STRING(30) and STRING(256) limit the length of usernames and emails
- STRING.BINARY for the hashedPassword to store binary data
- defaultValue for timestamps to automatically record creation and update times
The options object with schema handling is important for PostgreSQL in production, especially when deploying to platforms like Render.
Running the Migration
Let's run this migration to create the Users table:
// Command to run the migration
npx dotenv sequelize db:migrate
You can verify the migration was successful by checking your database:
// For SQLite (development)
sqlite3 db/dev.db ".schema Users"
User Model and Validations
Creating the User Model
The User model defines how we interact with the Users table from our code. It's important to add validations to ensure data integrity and security:
// backend/db/models/user.js
'use strict';
const { Model, Validator } = require('sequelize');
const bcrypt = require('bcryptjs');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
// This method will hash a password before a User is created
static async signup({ username, email, password, firstName, lastName }) {
const hashedPassword = bcrypt.hashSync(password);
const user = await User.create({
username,
email,
hashedPassword,
firstName,
lastName
});
return user;
}
// This method validates a login attempt
static async login({ credential, password }) {
const user = await User.unscoped().findOne({
where: {
[sequelize.Op.or]: {
username: credential,
email: credential
}
}
});
if (user && bcrypt.compareSync(password, user.hashedPassword.toString())) {
return user;
}
return null;
}
// Define associations to other models
static associate(models) {
// define association here
}
// Helper method to return a safe user object
toSafeObject() {
const { id, username, email, firstName, lastName } = this;
return { id, username, email, firstName, lastName };
}
}
User.init(
{
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
len: [4, 30],
isNotEmail(value) {
if (Validator.isEmail(value)) {
throw new Error('Cannot be an email.');
}
},
},
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
len: [3, 256],
isEmail: true,
},
},
hashedPassword: {
type: DataTypes.STRING.BINARY,
allowNull: false,
validate: {
len: [60, 60],
},
},
firstName: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [1, 50],
},
},
lastName: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [1, 50],
},
},
},
{
sequelize,
modelName: 'User',
defaultScope: {
attributes: {
exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt'],
},
},
scopes: {
// Include email and timestamps but not hashedPassword
currentUser: {
attributes: { exclude: ['hashedPassword'] }
},
// Include only the id and username
loginUser: {
attributes: {}
}
}
}
);
return User;
};
This model includes several important features:
- Model-level validations that ensure data quality
- Static methods for signup and login operations
- A toSafeObject method to return user data without sensitive information
- Model scopes to control what data is included in queries
Understanding Model Scopes
Model scopes help protect sensitive user information by controlling which fields are included in query results. We've defined:
- defaultScope: Excludes hashedPassword, email, and timestamps
- currentUser: Includes email and timestamps but excludes hashedPassword
- loginUser: Minimal information needed for login
These scopes will be used in different contexts to ensure that sensitive information is only accessible when needed.
Validation Logic
Our model includes several important validations:
- username: Must be 4-30 characters and cannot be an email
- email: Must be 3-256 characters and be a valid email format
- hashedPassword: Must be exactly 60 characters (bcrypt hash length)
- firstName/lastName: Must be 1-50 characters
The custom isNotEmail validator prevents users from using email addresses as usernames, which helps avoid confusion in the authentication system.
Seeding Test Users
Creating a User Seed File
To test our authentication system, we'll create seed data with demo users:
// Command to generate the seed file
npx sequelize seed:generate --name demo-user
Now, let's update the seed file to create test users:
// backend/db/seeders/XXXXXX-demo-user.js
'use strict';
const bcrypt = require('bcryptjs');
let options = {};
if (process.env.NODE_ENV === 'production') {
options.schema = process.env.SCHEMA; // define schema in options
}
module.exports = {
async up(queryInterface, Sequelize) {
options.tableName = 'Users';
return queryInterface.bulkInsert(options, [
{
email: 'demo@user.io',
username: 'Demo-lition',
hashedPassword: bcrypt.hashSync('password'),
firstName: 'Demo',
lastName: 'User',
createdAt: new Date(),
updatedAt: new Date()
},
{
email: 'user1@user.io',
username: 'FakeUser1',
hashedPassword: bcrypt.hashSync('password2'),
firstName: 'Fake',
lastName: 'User One',
createdAt: new Date(),
updatedAt: new Date()
},
{
email: 'user2@user.io',
username: 'FakeUser2',
hashedPassword: bcrypt.hashSync('password3'),
firstName: 'Fake',
lastName: 'User Two',
createdAt: new Date(),
updatedAt: new Date()
}
], {});
},
async down(queryInterface, Sequelize) {
options.tableName = 'Users';
const Op = Sequelize.Op;
return queryInterface.bulkDelete(options, {
username: { [Op.in]: ['Demo-lition', 'FakeUser1', 'FakeUser2'] }
}, {});
}
};
This seed file does several important things:
- Creates three test users with different credentials
- Uses bcrypt to hash the passwords before storing them
- Sets up proper timestamps for each user
- Includes a down function to remove these users if needed
Running the Seed File
Let's seed our database with these test users:
// Command to run the seed
npx dotenv sequelize db:seed:all
We can verify the seeding was successful by querying the database:
// For SQLite (development)
sqlite3 db/dev.db "SELECT username, email FROM Users"
This should show our three test users, confirming that the seed was successful.
Authentication Utilities
Now that we have our User model and database set up, we need to create utilities for handling authentication. These utilities will:
- Create and manage JWT tokens for user sessions
- Restore user information from JWTs
- Verify user authentication for protected routes
Creating the Auth Utilities
Let's create a new file for our authentication utilities:
// backend/utils/auth.js
const jwt = require('jsonwebtoken');
const { jwtConfig } = require('../config');
const { User } = require('../db/models');
const { secret, expiresIn } = jwtConfig;
// Sends a JWT Cookie
const setTokenCookie = (res, user) => {
// Create the token
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
firstName: user.firstName,
lastName: user.lastName
};
const token = jwt.sign(
{ data: safeUser },
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 user from 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.findByPk(id, {
attributes: ['id', 'email', 'username', 'firstName', 'lastName', 'createdAt', 'updatedAt']
});
} catch (e) {
res.clearCookie('token');
return next();
}
if (!req.user) res.clearCookie('token');
return next();
});
};
// Require authentication middleware
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 };
Let's examine each utility function in detail:
Understanding setTokenCookie
The setTokenCookie function:
- Creates a "safe" user object with non-sensitive information
- Signs a JWT token with this user data using the secret key
- Sets an HTTP-only cookie containing the token
- Configures cookie security based on the environment
This function will be used whenever a user logs in or signs up, establishing their authenticated session.
Understanding restoreUser
The restoreUser middleware:
- Extracts the JWT token from the request cookies
- Verifies the token's validity using the secret key
- If valid, retrieves the corresponding user from the database
- Attaches the user object to the request (
req.user) - Clears the cookie if the token is invalid or the user doesn't exist
This middleware will be used for all API routes to automatically restore the user's session if they have a valid token cookie.
Understanding requireAuth
The requireAuth middleware:
- Checks if
req.userexists (set byrestoreUser) - If it exists, allows the request to continue
- If not, creates an authentication error and passes it to the error handlers
This middleware will be used for routes that should only be accessible to authenticated users.
Testing Authentication Utilities
Before we implement the authentication routes, let's test our authentication utilities to ensure they're working correctly.
Setting Up Test Routes
Let's add some temporary test routes to our API router:
// backend/routes/api/index.js
// ... previous imports
const { setTokenCookie, restoreUser, requireAuth } = require('../../utils/auth.js');
const { User } = require('../../db/models');
// ... Connect restoreUser middleware
router.use(restoreUser);
// Test route for setting a token cookie
router.get('/set-token-cookie', async (_req, res) => {
const user = await User.findOne({
where: { username: 'Demo-lition' }
});
setTokenCookie(res, user);
return res.json({ user: user });
});
// Test route for restoring a user from a token
router.get('/restore-user', (req, res) => {
return res.json(req.user);
});
// Test route for requiring authentication
router.get('/require-auth', requireAuth, (req, res) => {
return res.json(req.user);
});
// ... rest of the file
These test routes will help us verify each component of our authentication system:
/api/set-token-cookie: Sets a token cookie for our demo user/api/restore-user: Returns the current user from the token cookie/api/require-auth: Tests the authentication requirement
Testing setTokenCookie
Let's test our token cookie setting functionality:
- Navigate to
http://localhost:8000/api/set-token-cookiein your browser - Check your browser's developer tools to verify a
tokencookie was set - Examine the response to confirm it contains the user information
Testing restoreUser
Now, let's test restoring the user from the token cookie:
- After setting the token cookie, navigate to
http://localhost:8000/api/restore-user - Verify that the response includes the user information
- Try removing the token cookie and refreshing to see a null user
Testing requireAuth
Finally, let's test the authentication requirement:
- With the token cookie set, navigate to
http://localhost:8000/api/require-auth - Verify that you see the user information
- Remove the token cookie and try again
- You should see an "Authentication required" error
Once we've verified that all our authentication utilities are working correctly, we can remove these test routes and proceed with implementing the actual authentication routes.
Connecting Authentication to the API
Now that we have our authentication utilities tested and working, we need to connect them to our API router to protect our endpoints.
Adding restoreUser to the API Router
Let's ensure that the restoreUser middleware is connected to 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);
// ... other route connections
module.exports = router;
With this middleware in place, every API route will automatically have access to the current user via req.user if they have a valid session.
Using requireAuth for Protected Routes
For routes that should only be accessible to authenticated users, we can use the requireAuth middleware:
// Example of a protected route
router.post('/protected-resource', requireAuth, (req, res) => {
// Only authenticated users can reach this code
// req.user is guaranteed to exist
return res.json({ message: 'You have access to this resource', user: req.user });
});
By adding requireAuth as middleware to specific routes, we ensure that only authenticated users can access those resources.
Understanding the Authentication Flow
With our authentication system in place, this is how the flow works:
- When a user logs in or signs up, we call
setTokenCookieto establish their session - On subsequent requests, the
restoreUsermiddleware automatically checks for a valid token - If the token is valid,
req.useris populated with the user's information - For protected routes, the
requireAuthmiddleware verifies thatreq.userexists - If the user is not authenticated, they receive a 401 Unauthorized error
This flow allows for a stateless authentication system where the server doesn't need to maintain session information in memory.
Security Considerations
Step 4: Review the Solution
Let's review our authentication implementation and discuss important security considerations:
Password Security
Our implementation uses several best practices for password security:
- Never storing plain-text passwords: We only store cryptographically hashed passwords
- Using bcrypt: A strong, industry-standard hashing algorithm designed specifically for passwords
- Salt integration: bcrypt automatically includes unique salts for each password hash
- Work factor control: bcrypt allows for adjusting computational intensity as hardware improves
Token Security
Our JWT implementation follows these security practices:
- HTTP-only cookies: Protects tokens from JavaScript access, mitigating XSS risks
- Secure flag in production: Ensures tokens are only sent over HTTPS
- SameSite policy: Protects against CSRF attacks
- Limited expiration time: Reduces the window of opportunity if a token is compromised
- Not storing sensitive data in tokens: The token payload only contains non-sensitive user information
Information Exposure
We protect sensitive user information in multiple ways:
- Model scopes: Limit what fields are returned in queries
- Safe user objects: Only include non-sensitive information when sending user data
- Controlled error messages: Providing helpful but not overly specific error messages
Additional Security Measures
To further enhance security, consider implementing:
- Rate limiting: Prevent brute force attacks on login endpoints
- Account lockout: Temporarily lock accounts after multiple failed login attempts
- Email verification: Verify user email addresses before allowing full access
- Two-factor authentication: Add an additional layer of security beyond passwords
- Password complexity requirements: Enforce stronger passwords
Real-World Authentication Systems
Understanding JWT-Based Authentication
Our implementation uses JWT-based authentication, which has become a standard in modern web applications. Here's why:
- Stateless architecture: The server doesn't need to store session information
- Scalability: Works well in distributed systems and microservices
- Cross-domain support: Can work across different domains and services
- Mobile application support: Works well for both web and mobile clients
Major services like Auth0, Firebase Authentication, and many enterprise applications use JWT-based authentication for these reasons.
JWT vs. Session-Based Authentication
It's worth understanding how JWT authentication compares to traditional session-based authentication:
JWT Authentication (our approach):
- Pros: Stateless, scalable, works across domains
- Cons: Can't be invalidated server-side, token size can be larger
Session-Based Authentication:
- Pros: Can be invalidated server-side, smaller client-side footprint
- Cons: Requires session storage, more complex in distributed systems
For many modern applications, especially those with microservice architectures, JWT authentication provides a better balance of security and scalability.
OAuth and Social Authentication
Many production applications extend basic authentication with OAuth for social login (Google, Facebook, GitHub, etc.). This could be a future enhancement to our system:
- Allow users to sign in with their existing accounts
- Simplify the registration process
- Leverage the security of major providers
- Add additional identity verification
Implementing OAuth would involve adding new routes for initiating OAuth flows and handling provider callbacks.
Conclusion
We've successfully implemented a robust user authentication system for our Express application. Our implementation includes:
- A secure database schema for storing user information
- A User model with
- A User model with proper validation and security features
- Secure password hashing with bcrypt
- JWT-based session management
- Authentication middleware for protecting routes
This authentication system provides a solid foundation for your Express application, ensuring that user accounts are secure and that restricted resources are properly protected.
Next Steps
To complete your authentication system, you should:
- Implement the actual authentication routes (signup, login, logout)
- Add input validation for authentication requests
- Create frontend components that interact with these routes
- Consider additional security features like email verification or password reset
Remember that authentication is a critical security component of your application. Always follow the latest security best practices and keep your dependencies updated to address any discovered vulnerabilities.
As your application grows, you may need to extend this authentication system with more advanced features like role-based access control, social authentication, or multi-factor authentication. The foundation we've built here will make those extensions easier to implement.
Extending Your Authentication System
Role-Based Access Control
If your application needs different permission levels, you can add a role field to your User model:
// In User model
role: {
type: DataTypes.ENUM('user', 'moderator', 'admin'),
allowNull: false,
defaultValue: 'user'
}
Then create middleware to check for specific roles:
// Role check middleware
const requireRole = (role) => (req, res, next) => {
if (!req.user) {
const err = new Error('Authentication required');
err.status = 401;
return next(err);
}
if (req.user.role !== role) {
const err = new Error('Forbidden');
err.status = 403;
return next(err);
}
next();
};
// Use in routes
router.delete('/admin/users/:id', requireAuth, requireRole('admin'), (req, res) => {
// Admin-only code
});
Email Verification
To add email verification:
- Add emailVerified and verificationToken fields to your User model
- Generate a verification token when a user signs up
- Send an email with a verification link
- Create a route to handle verification
- Optionally restrict certain features for unverified users
Password Reset
For password reset functionality:
- Add resetToken and resetTokenExpiry fields to your User model
- Create a route to request a password reset (generates token, sends email)
- Create a route to process the reset with a valid token
- Implement frontend forms for requesting and processing resets
Remember Me Functionality
To implement a "remember me" option:
- Add a remember parameter to your login route
- Adjust the token expiration time based on this parameter
- Update your login form to include a remember checkbox
// In setTokenCookie function
const tokenExpirationTime = remember ? 30 * 24 * 60 * 60 : 24 * 60 * 60; // 30 days or 1 day
const token = jwt.sign(
{ data: safeUser },
secret,
{ expiresIn: tokenExpirationTime }
);
res.cookie('token', token, {
maxAge: tokenExpirationTime * 1000,
httpOnly: true,
// other options
});