Express Authentication Middleware Testing: JWT and Sessions
Authentication middleware is the most security-critical code in your Express application. A bug in your JWT validation could mean an attacker with an expired token can still access protected routes. A session middleware misconfiguration can leak one user's data to another. This post covers how to write tests that actually catch these vulnerabilities.
The Authentication Middleware Architecture
Before writing tests, establish a clean, testable authentication stack. The key is making your token verification injectable so tests can control the behavior:
// middleware/auth.js
const jwt = require('jsonwebtoken');
const { UnauthorizedError, ForbiddenError } = require('../errors');
function createJwtMiddleware(options = {}) {
const {
secret = process.env.JWT_SECRET,
algorithms = ['HS256'],
getToken = defaultGetToken,
userService,
} = options;
return async (req, res, next) => {
try {
const token = getToken(req);
if (!token) {
throw new UnauthorizedError('No token provided');
}
const decoded = jwt.verify(token, secret, { algorithms });
// Optionally load the full user from db
if (userService) {
const user = await userService.findById(decoded.sub);
if (!user || !user.active) {
throw new UnauthorizedError('User not found or inactive');
}
req.user = user;
} else {
req.user = decoded;
}
next();
} catch (err) {
if (err instanceof UnauthorizedError) {
return next(err);
}
if (err.name === 'TokenExpiredError') {
return next(new UnauthorizedError('Token has expired'));
}
if (err.name === 'JsonWebTokenError') {
return next(new UnauthorizedError('Invalid token'));
}
next(new UnauthorizedError('Authentication failed'));
}
};
}
function defaultGetToken(req) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return null;
}
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return next(new UnauthorizedError('Authentication required'));
}
if (!roles.includes(req.user.role)) {
return next(new ForbiddenError(`Required role: ${roles.join(' or ')}`));
}
next();
};
}
module.exports = { createJwtMiddleware, requireRole };Generating Test Tokens
Tests need to create valid, invalid, and expired tokens. Keep token generation helpers in one place:
// test/helpers/tokens.js
const jwt = require('jsonwebtoken');
const TEST_SECRET = 'test-secret-key-not-for-production';
function signToken(payload, options = {}) {
return jwt.sign(payload, TEST_SECRET, {
expiresIn: '1h',
algorithm: 'HS256',
...options,
});
}
const tokens = {
valid: (userId = 'user-1', role = 'member') =>
signToken({ sub: userId, role, email: `${userId}@example.com` }),
expired: (userId = 'user-1') =>
signToken({ sub: userId, role: 'member' }, { expiresIn: '-1s' }),
wrongSignature: () =>
jwt.sign({ sub: 'user-1', role: 'member' }, 'wrong-secret'),
malformed: () => 'not.a.valid.jwt.token',
noExpiry: (userId = 'user-1') =>
signToken({ sub: userId, role: 'member' }, { expiresIn: undefined }),
withRole: (role) =>
signToken({ sub: 'user-1', role }),
admin: () =>
signToken({ sub: 'admin-1', role: 'admin', email: 'admin@example.com' }),
};
module.exports = { tokens, signToken, TEST_SECRET };Testing JWT Validation
// middleware/__tests__/jwt.test.js
const request = require('supertest');
const express = require('express');
const { createJwtMiddleware } = require('../auth');
const { tokens, TEST_SECRET } = require('../../test/helpers/tokens');
function buildProtectedApp(middlewareOptions = {}) {
const app = express();
const jwtMiddleware = createJwtMiddleware({
secret: TEST_SECRET,
...middlewareOptions,
});
app.get('/protected', jwtMiddleware, (req, res) => {
res.json({ user: req.user });
});
// Error handler
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message, code: err.code });
});
return app;
}
describe('JWT Middleware', () => {
describe('valid tokens', () => {
it('allows access with a valid Bearer token', async () => {
const app = buildProtectedApp();
const token = tokens.valid('user-42', 'member');
const response = await request(app)
.get('/protected')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body.user.sub).toBe('user-42');
expect(response.body.user.role).toBe('member');
});
it('attaches decoded payload to req.user', async () => {
const app = buildProtectedApp();
const token = tokens.valid('user-99', 'admin');
const response = await request(app)
.get('/protected')
.set('Authorization', `Bearer ${token}`);
expect(response.body.user).toMatchObject({
sub: 'user-99',
role: 'admin',
});
// Verify JWT internal fields are present
expect(response.body.user.iat).toBeDefined();
expect(response.body.user.exp).toBeDefined();
});
});
describe('invalid tokens', () => {
it('rejects requests with no Authorization header', async () => {
const app = buildProtectedApp();
const response = await request(app).get('/protected');
expect(response.status).toBe(401);
expect(response.body).toMatchObject({
error: 'No token provided',
code: 'UNAUTHORIZED',
});
});
it('rejects expired tokens', async () => {
const app = buildProtectedApp();
const token = tokens.expired();
const response = await request(app)
.get('/protected')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(401);
expect(response.body.error).toContain('expired');
});
it('rejects tokens signed with wrong secret', async () => {
const app = buildProtectedApp();
const token = tokens.wrongSignature();
const response = await request(app)
.get('/protected')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(401);
expect(response.body.code).toBe('UNAUTHORIZED');
});
it('rejects malformed tokens', async () => {
const app = buildProtectedApp();
const response = await request(app)
.get('/protected')
.set('Authorization', 'Bearer not.a.real.token');
expect(response.status).toBe(401);
});
it('rejects tokens missing the Bearer prefix', async () => {
const app = buildProtectedApp();
const token = tokens.valid();
const response = await request(app)
.get('/protected')
.set('Authorization', token); // No "Bearer " prefix
expect(response.status).toBe(401);
expect(response.body.error).toContain('No token provided');
});
it('rejects tokens using wrong algorithm', async () => {
const app = buildProtectedApp({ algorithms: ['HS256'] });
// Create a token signed with RS256 (different algorithm)
// In practice, test with a valid-looking but wrong-alg token
const tamperedHeader = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64');
const payload = Buffer.from(JSON.stringify({ sub: 'attacker', role: 'admin' })).toString('base64');
const noneToken = `${tamperedHeader}.${payload}.`;
const response = await request(app)
.get('/protected')
.set('Authorization', `Bearer ${noneToken}`);
expect(response.status).toBe(401);
});
});
});Testing Protected Routes
// routes/__tests__/protected.test.js
describe('Protected Route Access', () => {
it('public routes are accessible without authentication', async () => {
const response = await request(app).get('/api/health');
expect(response.status).toBe(200);
});
it('protected routes require authentication', async () => {
const protectedRoutes = [
{ method: 'get', path: '/api/profile' },
{ method: 'get', path: '/api/orders' },
{ method: 'post', path: '/api/orders' },
{ method: 'put', path: '/api/profile' },
];
for (const route of protectedRoutes) {
const response = await request(app)[route.method](route.path);
expect(response.status).toBe(401);
}
});
it('authenticated requests can access protected routes', async () => {
const token = tokens.valid('user-1', 'member');
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`);
// Should not be 401 or 403
expect(response.status).not.toBe(401);
expect(response.status).not.toBe(403);
});
});Testing Role-Based Access Control
// middleware/__tests__/roles.test.js
const { requireRole } = require('../auth');
function buildRoleApp() {
const app = express();
const jwtMiddleware = createJwtMiddleware({ secret: TEST_SECRET });
app.get('/member-only', jwtMiddleware, requireRole('member', 'admin'), (req, res) => {
res.json({ ok: true, role: req.user.role });
});
app.get('/admin-only', jwtMiddleware, requireRole('admin'), (req, res) => {
res.json({ ok: true, role: req.user.role });
});
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message, code: err.code });
});
return app;
}
describe('Role-Based Access Control', () => {
let app;
beforeEach(() => {
app = buildRoleApp();
});
it('allows members to access member-only routes', async () => {
const token = tokens.withRole('member');
const response = await request(app)
.get('/member-only')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(200);
});
it('allows admins to access member-only routes', async () => {
const token = tokens.admin();
const response = await request(app)
.get('/member-only')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(200);
});
it('blocks members from admin-only routes', async () => {
const token = tokens.withRole('member');
const response = await request(app)
.get('/admin-only')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(403);
expect(response.body.code).toBe('FORBIDDEN');
expect(response.body.error).toContain('admin');
});
it('blocks unauthenticated users from all role-protected routes', async () => {
const response = await request(app).get('/admin-only');
expect(response.status).toBe(401);
});
it('blocks unknown roles from accessing protected routes', async () => {
const token = tokens.withRole('superuser'); // invented role
const response = await request(app)
.get('/admin-only')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(403);
});
});Testing Token Refresh
Refresh token flows are stateful and require careful testing. Here's the pattern for testing a token refresh endpoint:
// routes/auth.js
router.post('/refresh', asyncHandler(async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
throw new UnauthorizedError('Refresh token required');
}
const session = await tokenStore.findByRefreshToken(refreshToken);
if (!session || session.expiresAt < new Date()) {
throw new UnauthorizedError('Invalid or expired refresh token');
}
// Rotate the refresh token
const newRefreshToken = crypto.randomBytes(32).toString('hex');
const accessToken = signToken({ sub: session.userId, role: session.role });
await tokenStore.rotate(refreshToken, newRefreshToken);
res.json({ accessToken, refreshToken: newRefreshToken });
}));// routes/__tests__/auth.refresh.test.js
describe('Token Refresh', () => {
let tokenStore;
let app;
beforeEach(() => {
tokenStore = {
findByRefreshToken: jest.fn(),
rotate: jest.fn().mockResolvedValue(true),
};
app = createApp({ tokenStore });
});
it('returns a new access token and refresh token for valid refresh token', async () => {
tokenStore.findByRefreshToken.mockResolvedValue({
userId: 'user-1',
role: 'member',
expiresAt: new Date(Date.now() + 86400000), // 24 hours from now
});
const response = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'valid-refresh-token' });
expect(response.status).toBe(200);
expect(response.body.accessToken).toBeDefined();
expect(response.body.refreshToken).toBeDefined();
expect(response.body.refreshToken).not.toBe('valid-refresh-token'); // rotated
expect(tokenStore.rotate).toHaveBeenCalledWith('valid-refresh-token', expect.any(String));
});
it('rejects expired refresh tokens', async () => {
tokenStore.findByRefreshToken.mockResolvedValue({
userId: 'user-1',
role: 'member',
expiresAt: new Date(Date.now() - 1000), // expired 1 second ago
});
const response = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'expired-refresh-token' });
expect(response.status).toBe(401);
expect(tokenStore.rotate).not.toHaveBeenCalled();
});
it('rejects unknown refresh tokens', async () => {
tokenStore.findByRefreshToken.mockResolvedValue(null);
const response = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'unknown-token' });
expect(response.status).toBe(401);
});
it('rejects requests with no refresh token body', async () => {
const response = await request(app)
.post('/api/auth/refresh')
.send({});
expect(response.status).toBe(401);
expect(response.body.error).toContain('Refresh token required');
});
it('new access token contains correct user claims', async () => {
tokenStore.findByRefreshToken.mockResolvedValue({
userId: 'user-special',
role: 'admin',
expiresAt: new Date(Date.now() + 86400000),
});
const response = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'some-token' });
expect(response.status).toBe(200);
// Decode the new access token and verify claims
const jwt = require('jsonwebtoken');
const decoded = jwt.decode(response.body.accessToken);
expect(decoded.sub).toBe('user-special');
expect(decoded.role).toBe('admin');
});
});Testing Session Middleware
For session-based authentication (using express-session), the pattern is similar but requires cookie handling:
// __tests__/session-auth.test.js
const session = require('supertest-session');
describe('Session Authentication', () => {
let testSession;
beforeEach(() => {
testSession = session(app);
});
it('login sets session cookie', async () => {
const response = await testSession
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'correct-password' });
expect(response.status).toBe(200);
expect(response.headers['set-cookie']).toBeDefined();
const cookieHeader = response.headers['set-cookie'][0];
expect(cookieHeader).toContain('HttpOnly');
expect(cookieHeader).toContain('SameSite=Strict');
});
it('session persists across requests', async () => {
// Login
await testSession
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'correct-password' });
// Access protected route using the same session
const profileResponse = await testSession.get('/api/profile');
expect(profileResponse.status).toBe(200);
expect(profileResponse.body.email).toBe('user@example.com');
});
it('logout destroys the session', async () => {
await testSession
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'correct-password' });
await testSession.post('/api/auth/logout');
const response = await testSession.get('/api/profile');
expect(response.status).toBe(401);
});
it('does not accept session after logout', async () => {
// Login and capture cookie
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'correct-password' });
const cookie = loginResponse.headers['set-cookie'][0];
// Logout
await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie);
// Try to use the old cookie
const response = await request(app)
.get('/api/profile')
.set('Cookie', cookie);
expect(response.status).toBe(401);
});
});Security-Specific Test Cases
These tests guard against specific attack patterns:
describe('Authentication Security', () => {
it('timing attack: login response time is consistent regardless of user existence', async () => {
const iterations = 5;
const timesForExisting = [];
const timesForMissing = [];
for (let i = 0; i < iterations; i++) {
const start = Date.now();
await request(app).post('/api/auth/login').send({ email: 'real@example.com', password: 'wrong' });
timesForExisting.push(Date.now() - start);
}
for (let i = 0; i < iterations; i++) {
const start = Date.now();
await request(app).post('/api/auth/login').send({ email: 'fake@example.com', password: 'wrong' });
timesForMissing.push(Date.now() - start);
}
const avgExisting = timesForExisting.reduce((a, b) => a + b) / iterations;
const avgMissing = timesForMissing.reduce((a, b) => a + b) / iterations;
// Response times should be within 50ms of each other
// This test is inherently flaky at high precision — use it as a smoke test
expect(Math.abs(avgExisting - avgMissing)).toBeLessThan(100);
});
it('failed login does not reveal whether email exists', async () => {
const existingUserResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'real@example.com', password: 'wrong-password' });
const nonExistentUserResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'doesnotexist@example.com', password: 'wrong-password' });
// Both should return the same generic error message
expect(existingUserResponse.body.error).toBe(nonExistentUserResponse.body.error);
expect(existingUserResponse.status).toBe(nonExistentUserResponse.status);
});
});Key Takeaways
Testing authentication middleware comprehensively requires covering:
- Every token failure mode — missing, expired, wrong signature, wrong algorithm, malformed, missing Bearer prefix
- Role enforcement boundaries — not just "admin can access", but "member cannot access admin routes"
- Refresh token rotation — the old token must be invalidated after rotation
- Session lifecycle — login, persistence, logout, post-logout rejection
- Security properties — consistent error messages that don't leak user existence, timing consistency
The token helper module is the most valuable investment — having a single place that generates all the token variants you need makes the actual test code readable and maintainable.