Express Authentication Middleware Testing: JWT and Sessions

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:

  1. Every token failure mode — missing, expired, wrong signature, wrong algorithm, malformed, missing Bearer prefix
  2. Role enforcement boundaries — not just "admin can access", but "member cannot access admin routes"
  3. Refresh token rotation — the old token must be invalidated after rotation
  4. Session lifecycle — login, persistence, logout, post-logout rejection
  5. 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.

Read more

Start now free