Fastify JWT Auth Testing: Protected Routes, Token Refresh, and RBAC
Authentication bugs are some of the most costly bugs to ship. Testing fastify-jwt means verifying that protected routes reject unauthenticated requests, that expired tokens fail gracefully, that the refresh flow issues new tokens correctly, and that RBAC rules enforce role boundaries. This post covers all four, with test patterns you can drop into an existing Fastify project.
Key Takeaways
Sign test tokens with the same secret your app uses. Don't hardcode a "test token" string. Sign a real JWT with your app's secret so the token structure and claims are identical to production tokens.
Test the 401 path before the 200 path. If the protected route works for everyone, auth is broken. Always write the rejection test first.
Expired token testing requires controlling time. Use jsonwebtoken's expiresIn option to sign short-lived tokens, or mock Date.now() to fast-forward. Never rely on actual clock delays in tests.
RBAC tests need at least three cases: allowed role, denied role, and no role. Two out of three isn't enough — a missing role claim should behave the same as a denied role, not an allowed one.
Mock the JWT verify step to unit test hooks in isolation. For unit tests of the auth hook itself, stub app.jwt.verify. For integration tests of route behavior, use a real signed token.
Setting Up fastify-jwt
npm install @fastify/jwt
npm install -D vitest jsonwebtoken @types/jsonwebtokenA typical auth setup:
// src/plugins/jwt.plugin.ts
import fp from 'fastify-plugin'
import fastifyJwt from '@fastify/jwt'
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
export interface JwtUser {
id: string
email: string
role: 'admin' | 'user' | 'moderator'
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: JwtUser
user: JwtUser
}
}
export default fp(async function jwtPlugin(app: FastifyInstance) {
await app.register(fastifyJwt, {
secret: process.env.JWT_SECRET ?? 'test-secret-key',
})
app.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or missing token' })
}
})
app.decorate('requireRole', function (role: JwtUser['role']) {
return async function (request: FastifyRequest, reply: FastifyReply) {
await request.jwtVerify()
if (request.user.role !== role) {
reply.code(403).send({ error: 'Forbidden', message: `Requires ${role} role` })
}
}
})
})Test Helpers: Signing Tokens
Create a dedicated helper that signs tokens using the same secret as the app:
// test/helpers/jwt.ts
import jwt from 'jsonwebtoken'
import { JwtUser } from '../../src/plugins/jwt.plugin'
const TEST_SECRET = 'test-secret-key'
export function signToken(payload: Partial<JwtUser> & { id: string }, options?: jwt.SignOptions): string {
return jwt.sign(
{ id: payload.id, email: payload.email ?? 'user@test.com', role: payload.role ?? 'user' },
TEST_SECRET,
{ expiresIn: '1h', ...options }
)
}
export function signAdminToken(): string {
return signToken({ id: 'admin-1', email: 'admin@test.com', role: 'admin' })
}
export function signUserToken(id = 'user-1'): string {
return signToken({ id, email: `${id}@test.com`, role: 'user' })
}
export function signExpiredToken(): string {
return signToken({ id: 'user-1' }, { expiresIn: '-1s' })
}Building the Test App
// test/helpers/build-app.ts
import Fastify, { FastifyInstance } from 'fastify'
import jwtPlugin from '../../src/plugins/jwt.plugin'
import { authRoutes } from '../../src/routes/auth'
import { protectedRoutes } from '../../src/routes/protected'
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false })
await app.register(jwtPlugin)
await app.register(authRoutes)
await app.register(protectedRoutes)
await app.ready()
return app
}Testing Protected Route Rejection
// test/routes/protected.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { FastifyInstance } from 'fastify'
import { buildApp } from '../helpers/build-app'
import { signUserToken, signExpiredToken } from '../helpers/jwt'
describe('Protected routes — authentication', () => {
let app: FastifyInstance
beforeEach(async () => { app = await buildApp() })
afterEach(async () => { await app.close() })
it('returns 401 when Authorization header is missing', async () => {
const response = await app.inject({
method: 'GET',
url: '/profile',
})
expect(response.statusCode).toBe(401)
expect(response.json()).toMatchObject({ error: 'Unauthorized' })
})
it('returns 401 when Authorization header is malformed', async () => {
const response = await app.inject({
method: 'GET',
url: '/profile',
headers: { authorization: 'NotBearer token' },
})
expect(response.statusCode).toBe(401)
})
it('returns 401 when token has an invalid signature', async () => {
const tampered = signUserToken() + 'tampered'
const response = await app.inject({
method: 'GET',
url: '/profile',
headers: { authorization: `Bearer ${tampered}` },
})
expect(response.statusCode).toBe(401)
})
it('returns 200 for authenticated requests with valid token', async () => {
const token = signUserToken('user-42')
const response = await app.inject({
method: 'GET',
url: '/profile',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toMatchObject({ id: 'user-42' })
})
})Testing Token Expiry
describe('Protected routes — token expiry', () => {
let app: FastifyInstance
beforeEach(async () => { app = await buildApp() })
afterEach(async () => { await app.close() })
it('returns 401 for expired tokens', async () => {
const expiredToken = signExpiredToken()
const response = await app.inject({
method: 'GET',
url: '/profile',
headers: { authorization: `Bearer ${expiredToken}` },
})
expect(response.statusCode).toBe(401)
})
it('includes meaningful error message for expired tokens', async () => {
const expiredToken = signExpiredToken()
const response = await app.inject({
method: 'GET',
url: '/profile',
headers: { authorization: `Bearer ${expiredToken}` },
})
// The error should not leak internal details, just say unauthorized
const body = response.json()
expect(body.error).toBe('Unauthorized')
// Should NOT include stack traces or jwt internals
expect(JSON.stringify(body)).not.toContain('jsonwebtoken')
})
})Testing Token Refresh
// src/routes/auth.ts (excerpt)
export async function authRoutes(app: FastifyInstance) {
app.post('/auth/refresh', {
preHandler: [app.authenticate],
}, async (request, reply) => {
// Issue a new token for the authenticated user
const newToken = app.jwt.sign({
id: request.user.id,
email: request.user.email,
role: request.user.role,
})
return reply.send({ token: newToken, type: 'Bearer' })
})
}describe('POST /auth/refresh', () => {
let app: FastifyInstance
beforeEach(async () => { app = await buildApp() })
afterEach(async () => { await app.close() })
it('issues a new token for authenticated users', async () => {
const token = signUserToken('user-1')
const response = await app.inject({
method: 'POST',
url: '/auth/refresh',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.token).toBeTruthy()
expect(body.type).toBe('Bearer')
// New token should be different from original
expect(body.token).not.toBe(token)
})
it('new token preserves the original user claims', async () => {
const token = signUserToken('user-99')
const refreshResponse = await app.inject({
method: 'POST',
url: '/auth/refresh',
headers: { authorization: `Bearer ${token}` },
})
const { token: newToken } = refreshResponse.json()
// Use the new token to access a protected route
const profileResponse = await app.inject({
method: 'GET',
url: '/profile',
headers: { authorization: `Bearer ${newToken}` },
})
expect(profileResponse.statusCode).toBe(200)
expect(profileResponse.json().id).toBe('user-99')
})
it('refuses to refresh an expired token', async () => {
const expiredToken = signExpiredToken()
const response = await app.inject({
method: 'POST',
url: '/auth/refresh',
headers: { authorization: `Bearer ${expiredToken}` },
})
expect(response.statusCode).toBe(401)
})
})Testing RBAC
Role-based access control needs at least three test cases per protected resource: the allowed role, a denied role, and an unauthenticated request.
// src/routes/admin.ts (excerpt)
export async function adminRoutes(app: FastifyInstance) {
app.get('/admin/users', {
preHandler: [app.requireRole('admin')],
}, async () => {
return { users: [] }
})
app.delete('/admin/users/:id', {
preHandler: [app.requireRole('admin')],
}, async (request) => {
return { deleted: (request.params as { id: string }).id }
})
}// test/routes/admin.test.ts
describe('Admin routes — RBAC', () => {
let app: FastifyInstance
beforeEach(async () => { app = await buildApp() })
afterEach(async () => { await app.close() })
describe('GET /admin/users', () => {
it('allows admin users', async () => {
const token = signAdminToken()
const response = await app.inject({
method: 'GET',
url: '/admin/users',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
})
it('denies regular users with 403', async () => {
const token = signUserToken()
const response = await app.inject({
method: 'GET',
url: '/admin/users',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
expect(response.json()).toMatchObject({ error: 'Forbidden' })
})
it('denies moderator users with 403', async () => {
const token = signToken({ id: 'mod-1', role: 'moderator' })
const response = await app.inject({
method: 'GET',
url: '/admin/users',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
})
it('returns 401 for unauthenticated requests', async () => {
const response = await app.inject({ method: 'GET', url: '/admin/users' })
expect(response.statusCode).toBe(401)
})
})
})Unit Testing the Auth Hook in Isolation
For unit tests that isolate the authenticate decorator from the rest of the app, mock jwtVerify directly:
// test/plugins/auth.unit.test.ts
import { describe, it, expect, vi } from 'vitest'
import Fastify from 'fastify'
import jwtPlugin from '../../src/plugins/jwt.plugin'
describe('authenticate decorator — unit', () => {
it('calls reply.code(401) when jwtVerify throws', async () => {
const app = Fastify({ logger: false })
await app.register(jwtPlugin)
app.get('/test', { preHandler: [app.authenticate] }, async () => ({ ok: true }))
await app.ready()
// Simulate a bad token — jwtVerify will throw internally
const response = await app.inject({
method: 'GET',
url: '/test',
headers: { authorization: 'Bearer invalid.token.here' },
})
expect(response.statusCode).toBe(401)
await app.close()
})
it('proceeds to handler when jwtVerify succeeds', async () => {
const app = Fastify({ logger: false })
await app.register(jwtPlugin)
app.get('/test', { preHandler: [app.authenticate] }, async () => ({ ok: true }))
await app.ready()
const token = app.jwt.sign({ id: 'u1', email: 'u@test.com', role: 'user' })
const response = await app.inject({
method: 'GET',
url: '/test',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true })
await app.close()
})
})What to Test vs. What to Skip
Test:
- Missing Authorization header → 401
- Malformed header (no
Bearerprefix) → 401 - Token with invalid signature → 401
- Expired token → 401
- Valid token → 200, with correct user claims available in handler
- Token refresh: new token issued, old claims preserved, expired token refused
- RBAC: allowed role passes, denied role gets 403, unauthenticated gets 401
- Error responses don't leak internal JWT library details
Skip:
- Testing that
@fastify/jwtcorrectly signs and verifies tokens — that's the library's test suite - Testing every possible JWT claim field unless your app uses them
- Signing tokens with a wildly different secret to "test wrong secret" — the invalid signature test already covers this
- Testing token decoding in unit tests when you can test the full HTTP path with inject() just as fast
Write the rejection test first. If it passes before you've written any auth code, something is wrong — your route isn't protected at all.