Fastify JWT Auth Testing: Protected Routes, Token Refresh, and RBAC

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/jsonwebtoken

A 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 Bearer prefix) → 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/jwt correctly 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.

Read more