Express.js Middleware Patterns: Authentication and Authorization
Production patterns for Express.js authentication and authorization middleware including JWT verification, refresh token rotation, RBAC, permission-based access, and composable middleware chains.
Express.js Middleware Patterns: Authentication and Authorization
Overview
Building secure APIs means getting authentication and authorization right. Express.js middleware gives you a clean, composable way to handle both -- but most tutorials stop at the basics and leave you with patterns that fall apart in production. This article covers the full picture: JWT verification, refresh token rotation, role-based and permission-based access control, middleware composition, and the error handling patterns that tie it all together.
Prerequisites
- Node.js 18+ installed
- Working knowledge of Express.js routing and basic middleware
- Familiarity with JSON Web Tokens (JWT) concepts
- Understanding of HTTP headers and status codes
How Express Middleware Works
Every middleware function receives three arguments: the request object, the response object, and a next function. You either end the request-response cycle by sending a response, or you call next() to pass control to the next middleware.
var logger = function(req, res, next) {
console.log(req.method + ' ' + req.path + ' - ' + Date.now());
next();
};
Middleware Execution Order
Order matters. Your authentication middleware attaches req.user; your authorization middleware reads it. Reverse the order and authorization fails.
var express = require('express');
var app = express();
// 1. Body parsing and security
app.use(express.json({ limit: '10kb' }));
// 2. Public routes (no auth required)
app.use('/api/auth', authRoutes);
app.use('/api/health', healthRoutes);
// 3. Authentication barrier
app.use('/api', authenticate);
// 4. Protected routes
app.use('/api/users', userRoutes);
app.use('/api/admin', authorize('admin'), adminRoutes);
// 5. Error handlers (must be last)
app.use(authErrorHandler);
app.use(globalErrorHandler);
Building an Authentication Middleware
var jwt = require('jsonwebtoken');
var JWT_SECRET = process.env.JWT_SECRET;
var JWT_ISSUER = process.env.JWT_ISSUER || 'myapp';
var authenticate = function(req, res, next) {
var authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'MISSING_TOKEN', message: 'Authorization header is required' });
}
var parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({ error: 'INVALID_TOKEN_FORMAT', message: 'Use Bearer scheme' });
}
try {
var decoded = jwt.verify(parts[1], JWT_SECRET, {
issuer: JWT_ISSUER,
algorithms: ['HS256'],
});
req.user = {
id: decoded.sub,
email: decoded.email,
role: decoded.role,
permissions: decoded.permissions || [],
};
req.token = parts[1];
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'TOKEN_EXPIRED', expiredAt: err.expiredAt });
}
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'INVALID_TOKEN' });
}
next(err);
}
};
Always specify algorithms: ['HS256']. Without it, an attacker could submit a token with none as the algorithm and bypass verification.
Refresh Token Rotation
Each refresh token can only be used once. Using a stale refresh token invalidates the entire token family -- this detects token theft.
var crypto = require('crypto');
var refreshTokenStore = new Map();
var generateTokenPair = function(user) {
var accessToken = jwt.sign(
{ sub: user.id, email: user.email, role: user.role, permissions: user.permissions },
JWT_SECRET,
{ expiresIn: '15m', issuer: JWT_ISSUER, algorithm: 'HS256' }
);
var refreshToken = crypto.randomBytes(64).toString('hex');
var tokenFamily = crypto.randomBytes(16).toString('hex');
var expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
refreshTokenStore.set(refreshToken, {
userId: user.id,
family: tokenFamily,
expiresAt: expiresAt,
used: false,
});
return { accessToken: accessToken, refreshToken: refreshToken, expiresIn: '15m' };
};
var rotateRefreshToken = async function(oldRefreshToken, getUserById) {
var tokenData = refreshTokenStore.get(oldRefreshToken);
if (!tokenData) return { error: 'INVALID_REFRESH_TOKEN' };
if (tokenData.used) {
// Token reuse detected -- invalidate entire family
for (var entry of refreshTokenStore.entries()) {
if (entry[1].family === tokenData.family) refreshTokenStore.delete(entry[0]);
}
return { error: 'REFRESH_TOKEN_REUSE_DETECTED' };
}
if (new Date() > tokenData.expiresAt) {
refreshTokenStore.delete(oldRefreshToken);
return { error: 'REFRESH_TOKEN_EXPIRED' };
}
tokenData.used = true;
var user = await getUserById(tokenData.userId);
if (!user) return { error: 'USER_NOT_FOUND' };
return generateTokenPair(user);
};
Role-Based Authorization
var authorize = function() {
var allowedRoles = Array.prototype.slice.call(arguments);
return function(req, res, next) {
if (!req.user) {
return res.status(401).json({ error: 'NOT_AUTHENTICATED' });
}
if (allowedRoles.indexOf(req.user.role) === -1) {
return res.status(403).json({
error: 'INSUFFICIENT_ROLE',
requiredRoles: allowedRoles,
currentRole: req.user.role,
});
}
next();
};
};
// Usage
router.get('/admin/dashboard', authorize('admin'), getDashboard);
router.put('/articles/:id', authorize('admin', 'editor'), updateArticle);
Permission-Based Access Control
var requirePermission = function() {
var requiredPerms = Array.prototype.slice.call(arguments);
return function(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'NOT_AUTHENTICATED' });
var userPerms = req.user.permissions || [];
var missing = requiredPerms.filter(function(p) { return userPerms.indexOf(p) === -1; });
if (missing.length > 0) {
return res.status(403).json({
error: 'INSUFFICIENT_PERMISSIONS',
missingPermissions: missing,
});
}
next();
};
};
// Usage with resource:action convention
router.post('/articles', requirePermission('articles:create'), createArticle);
router.delete('/articles/:id', requirePermission('articles:delete'), deleteArticle);
Composable Middleware Patterns
// Express supports multiple middleware per route natively
router.get('/admin/users', authenticate, authorize('admin'), listUsers);
// Or create named combinations for reuse
var adminOnly = [authenticate, authorize('admin')];
var canEditArticles = [authenticate, requirePermission('articles:update')];
router.get('/admin/users', adminOnly, listUsers);
router.put('/articles/:id', canEditArticles, updateArticle);
Optional Authentication
var optionalAuth = function(req, res, next) {
var authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) return next();
try {
var decoded = jwt.verify(authHeader.split(' ')[1], JWT_SECRET, { algorithms: ['HS256'] });
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role };
} catch (err) {
// Token invalid but auth is optional -- proceed without user
}
next();
};
Resource Ownership
var requireOwnerOrRole = function() {
var roles = Array.prototype.slice.call(arguments);
return function(req, res, next) {
if (req.isOwner) return next();
if (roles.indexOf(req.user.role) !== -1) return next();
return res.status(403).json({ error: 'FORBIDDEN', message: 'You can only modify your own resources' });
};
};
Error Handling Middleware
Express recognizes error handlers by their four parameters:
var authErrorHandler = function(err, req, res, next) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'TOKEN_EXPIRED', expiredAt: err.expiredAt });
}
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'INVALID_TOKEN' });
}
next(err);
};
var globalErrorHandler = function(err, req, res, next) {
console.error('Unhandled error:', err.message);
res.status(err.statusCode || 500).json({
error: 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message,
});
};
Complete Working Example
var express = require('express');
var jwt = require('jsonwebtoken');
var crypto = require('crypto');
var bcrypt = require('bcryptjs');
var app = express();
app.use(express.json({ limit: '10kb' }));
var CONFIG = {
jwtSecret: process.env.JWT_SECRET || 'change-this-in-production',
jwtIssuer: 'express-auth-demo',
accessTokenTTL: '15m',
port: process.env.PORT || 3000,
};
// In-memory stores (swap with your database in production)
var users = new Map();
var refreshTokens = new Map();
// Seed test users
(async function() {
users.set('user_1', {
id: 'user_1', email: '[email protected]',
password: await bcrypt.hash('password123', 12),
role: 'admin',
permissions: ['articles:read', 'articles:create', 'articles:update', 'articles:delete', 'users:manage'],
});
users.set('user_2', {
id: 'user_2', email: '[email protected]',
password: await bcrypt.hash('password123', 12),
role: 'editor',
permissions: ['articles:read', 'articles:create', 'articles:update'],
});
})();
// Middleware
var authenticate = function(req, res, next) {
var authHeader = req.headers.authorization;
if (!authHeader) return res.status(401).json({ error: 'MISSING_TOKEN' });
var parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') return res.status(401).json({ error: 'INVALID_TOKEN_FORMAT' });
try {
var decoded = jwt.verify(parts[1], CONFIG.jwtSecret, { issuer: CONFIG.jwtIssuer, algorithms: ['HS256'] });
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role, permissions: decoded.permissions || [] };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') return res.status(401).json({ error: 'TOKEN_EXPIRED' });
return res.status(401).json({ error: 'INVALID_TOKEN' });
}
};
var authorize = function() {
var roles = Array.prototype.slice.call(arguments);
return function(req, res, next) {
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'INSUFFICIENT_ROLE', requiredRoles: roles });
next();
};
};
var requirePermission = function() {
var perms = Array.prototype.slice.call(arguments);
return function(req, res, next) {
var missing = perms.filter(function(p) { return req.user.permissions.indexOf(p) === -1; });
if (missing.length > 0) return res.status(403).json({ error: 'INSUFFICIENT_PERMISSIONS', missing: missing });
next();
};
};
// Login
app.post('/api/auth/login', async function(req, res) {
var email = req.body.email;
var password = req.body.password;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
var foundUser = null;
for (var user of users.values()) { if (user.email === email) { foundUser = user; break; } }
if (!foundUser || !(await bcrypt.compare(password, foundUser.password))) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
}
var accessToken = jwt.sign(
{ sub: foundUser.id, email: foundUser.email, role: foundUser.role, permissions: foundUser.permissions },
CONFIG.jwtSecret, { expiresIn: CONFIG.accessTokenTTL, issuer: CONFIG.jwtIssuer, algorithm: 'HS256' }
);
var refreshToken = crypto.randomBytes(64).toString('hex');
var expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7);
refreshTokens.set(refreshToken, { userId: foundUser.id, expiresAt: expiresAt, used: false });
res.json({ accessToken: accessToken, refreshToken: refreshToken, user: { id: foundUser.id, email: foundUser.email, role: foundUser.role } });
});
// Protected routes
app.get('/api/articles', authenticate, function(req, res) {
res.json({ articles: [{ id: 1, title: 'Getting Started with Express' }], user: req.user.email });
});
app.post('/api/articles', authenticate, requirePermission('articles:create'), function(req, res) {
res.status(201).json({ message: 'Article created', article: { id: Date.now(), title: req.body.title, createdBy: req.user.id } });
});
app.get('/api/admin/users', authenticate, authorize('admin'), function(req, res) {
var userList = [];
for (var user of users.values()) { userList.push({ id: user.id, email: user.email, role: user.role }); }
res.json({ users: userList });
});
app.get('/api/me', authenticate, function(req, res) {
var user = users.get(req.user.id);
if (!user) return res.status(404).json({ error: 'USER_NOT_FOUND' });
res.json({ id: user.id, email: user.email, role: user.role, permissions: user.permissions });
});
// Error handlers
app.use(function(req, res) { res.status(404).json({ error: 'NOT_FOUND' }); });
app.use(function(err, req, res, next) {
console.error('Error:', err.message);
res.status(500).json({ error: 'INTERNAL_ERROR' });
});
app.listen(CONFIG.port, function() {
console.log('Auth demo running on port ' + CONFIG.port);
});
Common Issues and Troubleshooting
1. "jwt malformed" errors. You are passing the entire Authorization header value including "Bearer " to jwt.verify. Always split the header and use only the second part.
2. Middleware fires for routes it should not protect. Middleware registered with app.use() applies to all routes registered after it. Register public routes before authentication middleware.
3. Error-handling middleware is never called. Express requires all four parameters (err, req, res, next). Omitting next makes Express treat it as regular middleware.
4. CORS preflight requests fail authentication. Browsers send OPTIONS requests without Authorization headers. Make sure CORS middleware runs before authentication.
Best Practices
- Keep access tokens short-lived (15 minutes). Use refresh tokens for session continuity.
- Never store JWTs in localStorage. Use httpOnly cookies for browser clients.
- Use distinct error codes, not just HTTP status codes.
TOKEN_EXPIREDvsINVALID_TOKENtells the client exactly what to do. - Validate the
algheader. Always specifyalgorithmsinjwt.verify. - Rate-limit authentication endpoints aggressively. 5-10 requests per 15 minutes per IP.
- Log authentication events. Every login, logout, failure, and token refresh should produce a structured log entry.
- Use bcrypt or argon2 for password hashing. Never SHA-256 or MD5.
- Implement token revocation for sensitive operations like password changes.
