Authentication & Security
Securing your API is critical for protecting user data and preventing unauthorized access. This guide covers essential authentication and security practices for Express.js APIs, including JWT authentication, token storage strategies, role-based access control, and other security measures.
JWT Authentication
Section titled “JWT Authentication”What is JWT?
Section titled “What is JWT?”JSON Web Tokens (JWT) provide a compact, self-contained way to securely transmit information between parties as a JSON object. A JWT consists of three parts:
- Header: Contains the token type and signing algorithm used
- Payload: Contains the claims (user data and metadata)
- Signature: Ensures the token hasn’t been altered
JWTs are commonly used for:
- Authentication: Verifying the identity of users
- Authorization: Determining what resources a user can access
- Information Exchange: Securely transmitting data between parties
JWT Flow in Express.js Applications
Section titled “JWT Flow in Express.js Applications”- User logs in with credentials
- Server validates credentials and generates JWT
- JWT is sent to client
- Client stores JWT and sends it with subsequent requests
- Server validates JWT signature and grants access to protected resources
Implementing JWT Authentication
Section titled “Implementing JWT Authentication”Authentication Architecture
Section titled “Authentication Architecture”For a clean architecture, separate your authentication logic into:
- Controllers: Handle HTTP requests and responses
- Services: Contain business logic
- Utilities: Helper functions like JWT generation and verification
1. Set Up Required Packages
Section titled “1. Set Up Required Packages”npm install jsonwebtoken bcryptjs
2. Create JWT Utilities
Section titled “2. Create JWT Utilities”// utils/jwt.jsconst jwt = require('jsonwebtoken');
// Generate access tokenexports.generateAccessToken = (user) => { return jwt.sign( { id: user.id, email: user.email, role: user.role }, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' } // Short-lived access token );};
// Generate refresh tokenexports.generateRefreshToken = (user) => { return jwt.sign( { id: user.id }, process.env.JWT_REFRESH_SECRET, { expiresIn: '7d' } // Longer-lived refresh token );};
// Verify access tokenexports.verifyAccessToken = (token) => { return jwt.verify(token, process.env.JWT_ACCESS_SECRET);};
// Verify refresh tokenexports.verifyRefreshToken = (token) => { return jwt.verify(token, process.env.JWT_REFRESH_SECRET);};
3. Authentication Middleware
Section titled “3. Authentication Middleware”// middleware/authMiddleware.jsconst { StatusCodes } = require('http-status-codes');const User = require('../models/User');const verifyAccessToken = require('../utils/jwt');
const authMiddleware = async (req, res, next) => { try { // Get token from Authorization header const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(StatusCodes.UNAUTHORIZED).json({ statusCode: StatusCodes.UNAUTHORIZED, message: 'Authentication required', error: 'missing_token', path: req.originalUrl, timestamp: new Date().toISOString() }); }
// Extract the token const token = authHeader.split(' ')[1];
try { // Verify token const decoded = verifyAccessToken(token);
// Attach user to request req.user = decoded;
next(); } catch (tokenError) { // Specifically identify token expiration errors if (tokenError.name === 'TokenExpiredError') { return res.status(StatusCodes.UNAUTHORIZED).json({ statusCode: StatusCodes.UNAUTHORIZED, message: 'Token has expired', error: 'token_expired', path: req.originalUrl, timestamp: new Date().toISOString() }); }
// Other token verification errors return res.status(StatusCodes.UNAUTHORIZED).json({ statusCode: StatusCodes.UNAUTHORIZED, message: 'Invalid token', error: 'invalid_token', path: req.originalUrl, timestamp: new Date().toISOString() }); } } catch (error) { return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ statusCode: StatusCodes.INTERNAL_SERVER_ERROR, message: 'Internal server error', path: req.originalUrl, timestamp: new Date().toISOString()
4. Authentication Implementation
Section titled “4. Authentication Implementation”For a clean architecture, separate your authentication logic into controllers and services:
Authentication Service
Section titled “Authentication Service”The service layer contains the business logic related to authentication:
// services/authService.jsconst { StatusCodes } = require('http-status-codes');const { CustomError } = require('../utils/errorUtils');
exports.registerUser = async (userData) => { const { email, password, name } = userData;
// Check if user already exists const existingUser = await User.findOne({ email }); if (existingUser) { // Throw a proper error object instead of a plain object throw new CustomError('User already exists', StatusCodes.CONFLICT); }
// Hash password and create user const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(password, salt);
const user = await User.create({ email, password: hashedPassword, name, role: 'user' // Default role });
// Generate tokens const accessToken = generateAccessToken(user); const refreshToken = generateRefreshToken(user);
return { user, accessToken, refreshToken };};
// services/authService.jsexports.loginUser = async (credentials) => { const { email, password } = credentials;
// Find user and validate password const user = await User.findOne({ email }); if (!user) { throw new CustomError('Invalid credentials', StatusCodes.UNAUTHORIZED); }
const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { throw new CustomError('Invalid credentials', StatusCodes.UNAUTHORIZED); }
// Generate tokens const accessToken = generateAccessToken(user); const refreshToken = generateRefreshToken(user);
return { user, accessToken, refreshToken };};
// services/authService.jsexports.refreshUserToken = async (userId) => { // Find user by ID const user = await User.findById(userId); if (!user) { throw new CustomError('Invalid refresh token', StatusCodes.UNAUTHORIZED); }
// Generate new access token const accessToken = generateAccessToken(user);
return { accessToken };};
// services/authService.jsexports.logoutUser = async (userId) => { // You might implement token blacklisting here // For a stateless approach, the client simply discards the token
return true;};
Authentication Controller
Section titled “Authentication Controller”The controller layer handles HTTP requests and responses:
// controllers/authController.jsconst { StatusCodes } = require('http-status-codes');
exports.register = async (req, res) => { try { const authData = await authService.registerUser(req.body);
// Store refresh token in HttpOnly cookie res.cookie('refreshToken', authData.refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
// Return access token in response return res.status(StatusCodes.CREATED).json({ statusCode: StatusCodes.CREATED, data: { user: { id: authData.user.id, email: authData.user.email, name: authData.user.name, role: authData.user.role }, accessToken: authData.accessToken }, message: 'User registered successfully' }); } catch (error) { // Using a custom error class allows for better error handling return res.status(error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR).json({ statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, message: error.message || 'Error registering user', path: req.originalUrl, timestamp: new Date().toISOString() }); }};
// controllers/authController.jsexports.login = async (req, res) => { try { const authData = await authService.loginUser(req.body);
// Store refresh token in HttpOnly cookie res.cookie('refreshToken', authData.refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
// Return access token in response return res.status(StatusCodes.OK).json({ statusCode: StatusCodes.OK, data: { user: { id: authData.user.id, email: authData.user.email, name: authData.user.name, role: authData.user.role }, accessToken: authData.accessToken }, message: 'Login successful' }); } catch (error) { return res.status(error.status || StatusCodes.INTERNAL_SERVER_ERROR).json({ statusCode: error.status || StatusCodes.INTERNAL_SERVER_ERROR, message: error.message || 'Error logging in', path: req.originalUrl }); }};
// controllers/authController.jsexports.refreshToken = async (req, res) => { try { // Get refresh token from cookie const refreshToken = req.cookies.refreshToken;
if (!refreshToken) { return res.status(StatusCodes.UNAUTHORIZED).json({ statusCode: 401, message: 'Refresh token not found', error: 'missing_refresh_token', path: req.originalUrl }); }
try { // Verify the refresh token const decoded = verifyRefreshToken(refreshToken);
// Generate new access token const { accessToken } = await authService.refreshUserToken(decoded.id);
// Return new access token return res.status(StatusCodes.OK).json({ statusCode: 200, data: { accessToken }, message: 'Token refreshed successfully' }); } catch (tokenError) { // Handle specific refresh token errors if (tokenError.name === 'TokenExpiredError') { // Clear the expired refresh token res.clearCookie('refreshToken');
return res.status(StatusCodes.UNAUTHORIZED).json({ statusCode: 401, message: 'Refresh token has expired, please login again', error: 'refresh_token_expired', path: req.originalUrl }); }
return res.status(StatusCodes.UNAUTHORIZED).json({ statusCode: 401, message: 'Invalid refresh token', error: 'invalid_refresh_token', path: req.originalUrl }); } } catch (error) { return res.status(error.statusCode || 500).json({ statusCode: error.statusCode || 500, message: error.message || 'Error refreshing token', path: req.originalUrl }); }};
// controllers/authController.jsexports.logout = (req, res) => { // Clear refresh token cookie res.clearCookie('refreshToken');
return res.status(StatusCodes.OK).json({ statusCode: 200, message: 'Logged out successfully' });};
Token Refresh Flow
Section titled “Token Refresh Flow”The token refresh mechanism allows clients to obtain a new access token when the current one expires:
-
Client makes a request with the access token
The client includes the access token in the Authorization header for a protected resource.
-
Server detects an expired token
If the token is expired, the server responds with:
{"statusCode": 401,"message": "Token has expired","error": "token_expired"} -
Client requests a new access token
When the client receives the
token_expired
error, it makes a request to/api/auth/refresh-token
. The refresh token is automatically included in the request cookies. -
Server validates the refresh token
The server verifies the refresh token’s signature and expiration date.
-
Server generates a new access token
If the refresh token is valid, the server identifies the user and generates a new access token.
-
Client receives the new access token
The server returns the new access token in the response.
-
Client retries the original request
The client updates its stored access token and retries the original request that failed.
Token Storage Strategy
Section titled “Token Storage Strategy”Understanding Access and Refresh Tokens
Section titled “Understanding Access and Refresh Tokens”In a secure JWT implementation, two types of tokens are typically used:
-
Access Tokens:
- Short-lived (15-30 minutes)
- Used to access protected resources
- Contains user identity and permissions
- Sent with every API request
-
Refresh Tokens:
- Long-lived (days or weeks)
- Used only to obtain new access tokens
- Should be stored more securely
- Contains minimal information (usually just user ID)
Access Tokens in localStorage
Section titled “Access Tokens in localStorage”Access tokens are commonly stored in the browser’s localStorage or memory:
Benefits of localStorage for access tokens:
- Persists across page refreshes
- Easy to implement
- Available on all routes
- Not sent automatically with every request (unlike cookies)
When to use localStorage:
- For SPAs (Single Page Applications)
- In low to medium security requirements
- When implementing proper XSS protections
- For short-lived access tokens
Refresh Tokens in HttpOnly Cookies
Section titled “Refresh Tokens in HttpOnly Cookies”Refresh tokens are more sensitive and should be stored in HttpOnly cookies:
// Server sets the HttpOnly cookieres.cookie('refreshToken', refreshToken, { httpOnly: true, // Cannot be accessed by JavaScript secure: true, // Only sent over HTTPS sameSite: 'strict', // Protection against CSRF maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days});
Benefits of HttpOnly cookies for refresh tokens:
- Cannot be accessed by JavaScript (protects against XSS)
- Automatically sent with requests to the same domain
- Can be secured with additional flags (secure, sameSite)
- Browser handles cookie management
Role-Based Access Control (RBAC)
Section titled “Role-Based Access Control (RBAC)”Role-Based Authorization Middleware
Section titled “Role-Based Authorization Middleware”// middleware/rbac.jsexports.authorize = (...allowedRoles) => { return (req, res, next) => { // Check if user exists and has a role if (!req.user || !req.user.role) { return res.status(StatusCodes.FORBIDDEN).json({ statusCode: 403, message: 'Forbidden: Access denied', path: req.originalUrl }); }
// Check if user's role is allowed if (!allowedRoles.includes(req.user.role)) { return res.status(StatusCodes.FORBIDDEN).json({ statusCode: 403, message: 'Forbidden: Insufficient permissions', path: req.originalUrl }); }
// User has required role, proceed next(); };};
Permission-Based Authorization
Section titled “Permission-Based Authorization”For more granular control, you can implement permission-based authorization instead of or in addition to role-based control.
How permissions differ from roles:
- Roles are broad categories (admin, user, editor)
- Permissions are specific actions (create:post, delete:user, read:report)
- A role typically encompasses multiple permissions
- Permissions allow for more granular access control
Implementing permissions:
- Define a set of permission strings (e.g., ‘read:users’, ‘create:posts’)
- Assign permissions to users (either directly or via roles)
- Check for specific permissions in your middleware
Using RBAC in Routes
Section titled “Using RBAC in Routes”// routes/userRoutes.jsconst express = require('express');const router = express.Router();const userController = require('../controllers/userController');const { authenticate } = require('../middleware/auth');const { authorize } = require('../middleware/rbac');
// Public routerouter.get('/public-data', userController.getPublicData);
// Protected route - Any authenticated userrouter.get('/profile', authenticate, userController.getProfile);
// Role-based routesrouter.get('/users', authenticate, authorize('admin'), userController.getAllUsers);router.delete('/users/:id', authenticate, authorize('admin'), userController.deleteUser);router.put('/users/:id', authenticate, authorize('admin', 'moderator'), userController.updateUser);
module.exports = router;
Security Best Practices
Section titled “Security Best Practices”Rate Limiting and Brute Force Prevention
Section titled “Rate Limiting and Brute Force Prevention”Rate limiting is essential to prevent abuse, brute force attacks, and to ensure fair API usage.
// Basic rate limiting implementationconst rateLimit = require('express-rate-limit');
// Create rate limiter middlewareconst apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // 100 requests per IP in the windowMs timeframe standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers message: { statusCode: 429, message: 'Too many requests, please try again later' }});
// Apply to all routesapp.use(apiLimiter);
// Or apply to specific routesapp.use('/api/auth/', rateLimit({ windowMs: 15 * 60 * 1000, max: 5, // 5 requests per IP in 15 minutes message: { statusCode: 429, message: 'Too many login attempts, please try again later' }}));
CORS Configuration
Section titled “CORS Configuration”Cross-Origin Resource Sharing (CORS) is a security feature implemented by browsers to restrict requests from different origins.
// app.jsconst express = require('express');const cors = require('cors');const app = express();
// CORS optionsconst corsOptions = { origin: function (origin, callback) { const allowedOrigins = [ 'https://yourdomain.com', 'https://app.yourdomain.com' ];
// Allow requests with no origin (like mobile apps, curl, postman) if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true, // To allow cookies and authentication headers methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization']};
// Apply CORS middlewareapp.use(cors(corsOptions));
Security Headers with Helmet
Section titled “Security Headers with Helmet”Helmet helps secure Express apps by setting various HTTP headers to protect against common web vulnerabilities.
// Basic helmet setupconst helmet = require('helmet');
// Apply helmet middleware with default settingsapp.use(helmet());
CSRF Protection
Section titled “CSRF Protection”For APIs that use cookies for authentication, CSRF protection is essential:
// Basic CSRF protection setupconst { doubleCsrf } = require('csrf-csrf');
// Configure CSRF protectionconst { invalidCsrfTokenError, // Error factory for invalid tokens generateToken, // Function to generate tokens validateRequest, // Function to validate requests doubleCsrfProtection, // Express middleware} = doubleCsrf({ getSecret: () => 'your-csrf-secret', cookieName: 'x-csrf-token', cookieOptions: { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production' }, size: 64, // token size ignoredMethods: ['GET', 'HEAD', 'OPTIONS']});
// Apply CSRF protection middlewareapp.use(doubleCsrfProtection);
Common Pitfalls & Best Practices
Section titled “Common Pitfalls & Best Practices”Best Practices
Section titled “Best Practices”-
Never store sensitive data in tokens
- Keep tokens lightweight with minimal claims
-
Implement proper error handling
- Don’t expose sensitive information in error messages
-
Audit and log authentication events
- Track login attempts, password changes, and permission changes
-
Rotate JWT secrets periodically
- Use a secret management solution for production
-
Consider using multi-factor authentication
- For high-security applications
Common Pitfalls
Section titled “Common Pitfalls”❌ Storing JWTs in localStorage only
- Vulnerable to XSS attacks
- Better: Use HttpOnly cookies for refresh tokens
❌ Using the same secret for access and refresh tokens
- Compromised secret affects all tokens
- Better: Use separate secrets for different token types
❌ Setting very long token expiration
- Increases security risk if token is compromised
- Better: Short-lived access tokens with refresh capability
❌ Not validating token contents
- Always verify all claims in the token
- Check for expiration, issuer, audience, etc.
❌ Storing sensitive data in tokens
- Tokens can be decoded even if they’re signed
- Better: Store only identification information in tokens