Testing JWT Authentication Flows with Supertest
Authentication bugs are the most expensive bugs to ship. Supertest lets you test the entire JWT lifecycle — login, token issuance, protected route access, refresh, and revocation — at the HTTP layer, exactly as a real client would interact with your API.
Key Takeaways
Test authentication at the HTTP boundary. Mocking JWT verification in unit tests does not catch misconfigured middleware, missing route guards, or wrong status codes for expired tokens.
Issue a real token in beforeAll and reuse it. One login per suite; share the token across tests that need it. Never re-authenticate inside individual test cases.
Test negative cases explicitly. Missing tokens, expired tokens, tampered signatures, and wrong roles must all return the right HTTP status and error shape.
Keep helper functions small. A login(email, password) helper returning { token, userId } keeps your test setup clean and easy to update when the auth API changes.
Test the refresh cycle end-to-end. Issue a token, wait (or mock time), call the refresh endpoint, verify the new token works, and verify the old one no longer works.
Why Test Authentication at the HTTP Level
Unit tests can verify that your JwtService.sign() call uses the right secret, but they cannot tell you:
- Whether your Express/Fastify/Koa middleware is actually attached to the right routes
- Whether a guard returns
401or403for the right conditions - Whether the
Authorizationheader parsing handles edge cases (missingBearerprefix, extra whitespace, lowercase header name) - Whether the
Set-Cookieresponse on login sets the rightSecureandHttpOnlyflags
Supertest spins up your actual server and sends real HTTP requests. Every layer participates.
Project Setup
A minimal Express + JWT setup:
npm install express jsonwebtoken bcrypt
npm install --save-dev supertest jest @types/supertest// src/app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET || 'test-secret';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret';
// In-memory user store for examples
const users = new Map();
const refreshTokens = new Set();
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'email and password required' });
if (users.has(email)) return res.status(409).json({ error: 'email already registered' });
const hash = await bcrypt.hash(password, 10);
const id = `user_${Date.now()}`;
users.set(email, { id, email, hash, role: 'user' });
res.status(201).json({ id, email });
});
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = users.get(email);
if (!user) return res.status(401).json({ error: 'invalid credentials' });
const valid = await bcrypt.compare(password, user.hash);
if (!valid) return res.status(401).json({ error: 'invalid credentials' });
const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ sub: user.id }, REFRESH_SECRET, { expiresIn: '7d' });
refreshTokens.add(refreshToken);
res.json({ token, refreshToken, expiresIn: 900 });
});
app.post('/auth/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken || !refreshTokens.has(refreshToken)) {
return res.status(401).json({ error: 'invalid refresh token' });
}
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
const token = jwt.sign({ sub: payload.sub, role: 'user' }, SECRET, { expiresIn: '15m' });
res.json({ token, expiresIn: 900 });
} catch {
res.status(401).json({ error: 'refresh token expired' });
}
});
app.post('/auth/logout', (req, res) => {
const { refreshToken } = req.body;
refreshTokens.delete(refreshToken);
res.status(204).send();
});
// Auth middleware
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'missing token' });
try {
req.user = jwt.verify(header.slice(7), SECRET);
next();
} catch (err) {
if (err.name === 'TokenExpiredError') return res.status(401).json({ error: 'token expired' });
res.status(401).json({ error: 'invalid token' });
}
}
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'forbidden' });
next();
};
}
app.get('/profile', authenticate, (req, res) => {
res.json({ userId: req.user.sub, role: req.user.role });
});
app.delete('/admin/users/:id', authenticate, authorize('admin'), (req, res) => {
res.json({ deleted: req.params.id });
});
module.exports = { app, users, refreshTokens };Test Helper: Login Once, Reuse Everywhere
// tests/helpers/auth.js
const request = require('supertest');
const { app } = require('../../src/app');
async function register(email = 'test@example.com', password = 'password123') {
const res = await request(app).post('/auth/register').send({ email, password });
if (res.status !== 201) throw new Error(`Register failed: ${JSON.stringify(res.body)}`);
return res.body;
}
async function login(email = 'test@example.com', password = 'password123') {
const res = await request(app).post('/auth/login').send({ email, password });
if (res.status !== 200) throw new Error(`Login failed: ${JSON.stringify(res.body)}`);
return res.body; // { token, refreshToken, expiresIn }
}
module.exports = { register, login };Testing the Login Flow
// tests/auth/login.test.js
const request = require('supertest');
const { app, users } = require('../../src/app');
const { register } = require('../helpers/auth');
describe('POST /auth/login', () => {
const email = 'login-test@example.com';
const password = 'securepass';
beforeAll(async () => {
await register(email, password);
});
afterAll(() => {
users.delete(email);
});
it('returns 200 with token and refreshToken on valid credentials', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email, password });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
token: expect.any(String),
refreshToken: expect.any(String),
expiresIn: 900,
});
});
it('token is a valid JWT with expected payload', async () => {
const jwt = require('jsonwebtoken');
const res = await request(app).post('/auth/login').send({ email, password });
const decoded = jwt.decode(res.body.token);
expect(decoded.sub).toEqual(expect.any(String));
expect(decoded.role).toBe('user');
expect(decoded.exp - decoded.iat).toBe(900);
});
it('returns 401 with wrong password', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email, password: 'wrong-password' });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid credentials' });
});
it('returns 401 for non-existent user', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email: 'nobody@example.com', password });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid credentials' });
});
it('returns 400 when email is missing', async () => {
const res = await request(app)
.post('/auth/login')
.send({ password });
expect(res.status).toBe(400);
});
it('returns 400 when password is missing', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email });
expect(res.status).toBe(400);
});
});Testing Protected Routes
// tests/auth/protected.test.js
const request = require('supertest');
const jwt = require('jsonwebtoken');
const { app, users } = require('../../src/app');
const { register, login } = require('../helpers/auth');
describe('Protected routes', () => {
const email = 'protected-test@example.com';
const password = 'securepass';
let token;
beforeAll(async () => {
await register(email, password);
const auth = await login(email, password);
token = auth.token;
});
afterAll(() => {
users.delete(email);
});
describe('GET /profile', () => {
it('returns 200 with user data when token is valid', async () => {
const res = await request(app)
.get('/profile')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
userId: expect.any(String),
role: 'user',
});
});
it('returns 401 when Authorization header is missing', async () => {
const res = await request(app).get('/profile');
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'missing token' });
});
it('returns 401 when token is malformed', async () => {
const res = await request(app)
.get('/profile')
.set('Authorization', 'Bearer not.a.jwt');
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid token' });
});
it('returns 401 when token is expired', async () => {
// Sign a token that's already expired
const expired = jwt.sign(
{ sub: 'user_123', role: 'user' },
process.env.JWT_SECRET || 'test-secret',
{ expiresIn: -1 } // already expired
);
const res = await request(app)
.get('/profile')
.set('Authorization', `Bearer ${expired}`);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'token expired' });
});
it('returns 401 when token is signed with wrong secret', async () => {
const fakeToken = jwt.sign({ sub: 'user_123', role: 'user' }, 'wrong-secret');
const res = await request(app)
.get('/profile')
.set('Authorization', `Bearer ${fakeToken}`);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid token' });
});
it('returns 401 when Bearer prefix is missing', async () => {
const res = await request(app)
.get('/profile')
.set('Authorization', token); // no "Bearer " prefix
expect(res.status).toBe(401);
});
});
});Testing Role-Based Access Control
// tests/auth/rbac.test.js
const request = require('supertest');
const jwt = require('jsonwebtoken');
const { app, users } = require('../../src/app');
const { register, login } = require('../helpers/auth');
describe('Role-based access control', () => {
const userEmail = 'rbac-user@example.com';
const password = 'password123';
let userToken;
let adminToken;
beforeAll(async () => {
// Create regular user
await register(userEmail, password);
const userAuth = await login(userEmail, password);
userToken = userAuth.token;
// Mint an admin token directly (no register endpoint for admins in this example)
adminToken = jwt.sign(
{ sub: 'admin_001', role: 'admin' },
process.env.JWT_SECRET || 'test-secret',
{ expiresIn: '15m' }
);
});
afterAll(() => {
users.delete(userEmail);
});
it('allows admin to delete a user', async () => {
const res = await request(app)
.delete('/admin/users/some-user-id')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ deleted: 'some-user-id' });
});
it('blocks regular user from admin endpoint with 403', async () => {
const res = await request(app)
.delete('/admin/users/some-user-id')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'forbidden' });
});
it('blocks unauthenticated request from admin endpoint with 401', async () => {
const res = await request(app).delete('/admin/users/some-user-id');
expect(res.status).toBe(401);
});
});Testing the Token Refresh Cycle
// tests/auth/refresh.test.js
const request = require('supertest');
const { app, users } = require('../../src/app');
const { register, login } = require('../helpers/auth');
describe('Token refresh flow', () => {
const email = 'refresh-test@example.com';
const password = 'password123';
let initialToken;
let refreshToken;
beforeAll(async () => {
await register(email, password);
const auth = await login(email, password);
initialToken = auth.token;
refreshToken = auth.refreshToken;
});
afterAll(() => {
users.delete(email);
});
it('issues a new access token given a valid refresh token', async () => {
const res = await request(app)
.post('/auth/refresh')
.send({ refreshToken });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
token: expect.any(String),
expiresIn: 900,
});
// New token should differ from original
expect(res.body.token).not.toBe(initialToken);
});
it('new access token works on protected routes', async () => {
const refreshRes = await request(app)
.post('/auth/refresh')
.send({ refreshToken });
const newToken = refreshRes.body.token;
const profileRes = await request(app)
.get('/profile')
.set('Authorization', `Bearer ${newToken}`);
expect(profileRes.status).toBe(200);
});
it('returns 401 for unknown refresh token', async () => {
const res = await request(app)
.post('/auth/refresh')
.send({ refreshToken: 'made-up-token' });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid refresh token' });
});
it('returns 401 after logout (refresh token revoked)', async () => {
// Login fresh to get a new refresh token to revoke
const auth = await login(email, password);
const tokenToRevoke = auth.refreshToken;
await request(app)
.post('/auth/logout')
.send({ refreshToken: tokenToRevoke });
const res = await request(app)
.post('/auth/refresh')
.send({ refreshToken: tokenToRevoke });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid refresh token' });
});
});Testing Cookie-Based Auth (HttpOnly Tokens)
If your app uses Set-Cookie instead of JSON body for tokens:
// tests/auth/cookie-auth.test.js
const request = require('supertest');
describe('Cookie-based auth', () => {
let agent;
beforeEach(() => {
// supertest agent persists cookies across requests
agent = request.agent(app);
});
it('sets HttpOnly cookie on login', async () => {
const res = await agent
.post('/auth/login')
.send({ email: 'user@example.com', password: 'pass' });
expect(res.status).toBe(200);
const cookie = res.headers['set-cookie'];
expect(cookie).toBeDefined();
expect(cookie[0]).toMatch(/HttpOnly/i);
expect(cookie[0]).toMatch(/Secure/i);
expect(cookie[0]).toMatch(/SameSite=Strict/i);
});
it('includes cookie automatically in subsequent requests', async () => {
// Login
await agent.post('/auth/login').send({ email: 'user@example.com', password: 'pass' });
// Cookie is automatically included by the agent
const res = await agent.get('/profile');
expect(res.status).toBe(200);
});
it('clears cookie on logout', async () => {
await agent.post('/auth/login').send({ email: 'user@example.com', password: 'pass' });
const logoutRes = await agent.post('/auth/logout');
// Check cookie is cleared (Max-Age=0 or expired date)
const cookie = logoutRes.headers['set-cookie'];
expect(cookie[0]).toMatch(/Max-Age=0|expires=Thu, 01 Jan 1970/i);
});
});Common Mistakes
Testing with mocked JWT. Mocking jwt.verify in your routes file defeats the purpose. Test the actual HTTP middleware chain.
Sharing mutable state between tests. If one test registers a user and another test in the same suite deletes them, tests become order-dependent. Use unique emails per suite or clean up reliably in afterAll.
Not testing the expired token case. jwt.sign({ ... }, secret, { expiresIn: -1 }) gives you an already-expired token without needing to wait or mock Date.now.
Trusting 2xx is enough. Always assert the response body shape — a 200 with { error: "..." } is a bug.
Not testing missing headers. The Authorization header parsing is a common source of bugs — test missing, malformed, and case-insensitive variations.
Integrating with HelpMeTest
Once your Supertest suite is passing locally, you can run it as part of a continuous monitoring workflow. HelpMeTest's CI/CD integration can trigger your test suite on every deploy and alert you when auth regressions appear in production — catching issues like expired SSL certificates invalidating JWT verification before your users notice.
The pattern: Supertest covers the contract; HelpMeTest monitors the running service. Together they close the gap between "tests pass in CI" and "auth works in production."
Summary
Testing JWT authentication with Supertest means exercising the entire middleware chain:
- Login — valid credentials, wrong password, missing fields, non-existent user
- Protected routes — valid token, missing token, malformed token, expired token, wrong secret
- RBAC — correct role allows, wrong role blocks with 403, no auth blocks with 401
- Refresh cycle — new token issued, new token works, revoked token rejected
- Cookie auth — HttpOnly/Secure flags, agent-based cookie persistence, logout clears cookie
The key discipline: issue tokens once in beforeAll, reuse across tests, and always assert the error body — not just the status code.