Introduction to Vitest Authentication Testing
Authentication is one of the most security-critical parts of any application, and testing it thoroughly is non-negotiable. Vitest, the blazing-fast test runner built on Vite, offers an excellent developer experience for testing authentication flows including JWT tokens, session-based auth, and OAuth integrations. In this tutorial, we'll explore how to set up and test each of these authentication strategies effectively using Vitest.
What Is Vitest Authentication Testing?
Vitest authentication testing refers to the practice of writing unit, integration, and end-to-end tests that verify your authentication logic works as expected. This includes testing token generation, validation, session management, cookie handling, and third-party OAuth flows. Because Vitest runs natively in the Vite ecosystem, it integrates seamlessly with modern frameworks like Nuxt, SvelteKit, and Remix, all of which ship with built-in auth utilities.
Why It Matters
- Security: Broken authentication is consistently ranked in the OWASP Top 10. Tests catch regressions before they reach production.
- Confidence: Refactoring auth logic without tests is risky. Tests give you a safety net.
- Speed: Vitest's native ESM support and watch mode make auth test iteration nearly instantaneous.
- Compliance: Many industries require documented test coverage for authentication mechanisms.
Setting Up Vitest for Authentication Tests
Before diving into specific auth strategies, let's establish a baseline Vitest configuration. Install Vitest and any mocking utilities you'll need.
npm install -D vitest @vitest/coverage-v8
Create a vitest.config.ts file at the root of your project:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/auth/**'],
},
},
})
The setup file is where you'll place shared mocks and utilities:
// tests/setup.ts
import { vi, beforeEach } from 'vitest'
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
Testing JWT Authentication
JSON Web Tokens are stateless, signed tokens that carry claims about a user. Testing JWT auth involves verifying token generation, signature validation, expiration handling, and claim extraction.
The JWT Utility Module
Here's a typical JWT utility module we'll be testing:
// src/auth/jwt.ts
import jwt from 'jsonwebtoken'
const SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'
const ACCESS_EXPIRES = '15m'
const REFRESH_EXPIRES = '7d'
export function signAccessToken(payload: object): string {
return jwt.sign(payload, SECRET, { expiresIn: ACCESS_EXPIRES })
}
export function signRefreshToken(payload: object): string {
return jwt.sign(payload, SECRET, { expiresIn: REFRESH_EXPIRES })
}
export function verifyToken(token: string): jwt.JwtPayload {
try {
return jwt.verify(token, SECRET) as jwt.JwtPayload
} catch (err) {
throw new Error('Invalid or expired token')
}
}
export function decodeToken(token: string): jwt.JwtPayload | null {
return jwt.decode(token) as jwt.JwtPayload | null
}
Writing JWT Tests
Now let's write comprehensive tests for this module:
// tests/auth/jwt.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import jwt from 'jsonwebtoken'
import { signAccessToken, signRefreshToken, verifyToken, decodeToken } from '../../src/auth/jwt'
describe('JWT Authentication', () => {
const payload = { userId: '123', role: 'admin' }
beforeEach(() => {
process.env.JWT_SECRET = 'test-secret'
})
describe('signAccessToken', () => {
it('should generate a valid JWT string', () => {
const token = signAccessToken(payload)
expect(token).toBeTypeOf('string')
expect(token.split('.')).toHaveLength(3)
})
it('should embed the payload in the token', () => {
const token = signAccessToken(payload)
const decoded = decodeToken(token)
expect(decoded?.userId).toBe('123')
expect(decoded?.role).toBe('admin')
})
it('should set an expiration time', () => {
const token = signAccessToken(payload)
const decoded = decodeToken(token)
expect(decoded?.exp).toBeDefined()
expect(decoded?.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
})
})
describe('verifyToken', () => {
it('should return the decoded payload for a valid token', () => {
const token = signAccessToken(payload)
const result = verifyToken(token)
expect(result.userId).toBe('123')
})
it('should throw for a tampered token', () => {
const token = signAccessToken(payload) + 'tampered'
expect(() => verifyToken(token)).toThrow('Invalid or expired token')
})
it('should throw for an expired token', () => {
vi.useFakeTimers()
const token = jwt.sign(payload, 'test-secret', { expiresIn: '1s' })
vi.advanceTimersByTime(2000)
expect(() => verifyToken(token)).toThrow('Invalid or expired token')
vi.useRealTimers()
})
it('should throw for a token signed with a different secret', () => {
const token = jwt.sign(payload, 'wrong-secret')
expect(() => verifyToken(token)).toThrow('Invalid or expired token')
})
})
describe('signRefreshToken', () => {
it('should have a longer expiration than access tokens', () => {
const access = decodeToken(signAccessToken(payload))
const refresh = decodeToken(signRefreshToken(payload))
expect(refresh!.exp! - refresh!.iat!).toBeGreaterThan(access!.exp! - access!.iat!)
})
})
})
Testing JWT Middleware
Most applications use middleware to protect routes. Here's how to test an Express-style auth middleware:
// src/auth/middleware.ts
import { verifyToken } from './jwt'
import type { Request, Response, NextFunction } from 'express'
export function authenticate(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' })
}
try {
const token = header.split(' ')[1]
req.user = verifyToken(token)
next()
} catch {
return res.status(401).json({ error: 'Invalid token' })
}
}
// tests/auth/middleware.test.ts
import { describe, it, expect, vi } from 'vitest'
import { authenticate } from '../../src/auth/middleware'
import * as jwtModule from '../../src/auth/jwt'
function mockReq(headers: Record = {}) {
return { headers } as any
}
function mockRes() {
const res: any = {}
res.status = vi.fn().ReturnValue(res)
res.json = vi.fn().ReturnValue(res)
return res
}
describe('authenticate middleware', () => {
it('should reject requests without authorization header', () => {
const req = mockReq()
const res = mockRes()
const next = vi.fn()
authenticate(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('should reject malformed authorization headers', () => {
const req = mockReq({ authorization: 'Basic abc123' })
const res = mockRes()
const next = vi.fn()
authenticate(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
})
it('should call next and attach user for valid tokens', () => {
const req = mockReq({ authorization: 'Bearer valid-token' })
const res = mockRes()
const next = vi.fn()
vi.spyOn(jwtModule, 'verifyToken').mockReturnValue({ userId: '42' })
authenticate(req, res, next)
expect(next).toHaveBeenCalled()
expect((req as any).user).toEqual({ userId: '42' })
})
it('should reject invalid tokens', () => {
const req = mockReq({ authorization: 'Bearer bad-token' })
const res = mockRes()
const next = vi.fn()
vi.spyOn(jwtModule, 'verifyToken').mockImplementation(() => {
throw new Error('Invalid')
})
authenticate(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
})
Testing Session-Based Authentication
Session-based authentication stores user state on the server, typically in a database or in-memory store, and sends a session ID to the client via a cookie. Testing sessions requires mocking the session store and verifying cookie behavior.
Session Manager Implementation
// src/auth/session.ts
import { randomUUID } from 'crypto'
interface SessionData {
userId: string
createdAt: number
expiresAt: number
}
export class SessionManager {
private store = new Map<string, SessionData>()
private ttl: number
constructor(ttlMs = 1000 * 60 * 30) {
this.ttl = ttlMs
}
createSession(userId: string): string {
const sessionId = randomUUID()
const now = Date.now()
this.store.set(sessionId, {
userId,
createdAt: now,
expiresAt: now + this.ttl,
})
return sessionId
}
getSession(sessionId: string): SessionData | null {
const session = this.store.get(sessionId)
if (!session) return null
if (Date.now() > session.expiresAt) {
this.store.delete(sessionId)
return null
}
return session
}
destroySession(sessionId: string): boolean {
return this.store.delete(sessionId)
}
refreshSession(sessionId: string): boolean {
const session = this.store.get(sessionId)
if (!session) return false
session.expiresAt = Date.now() + this.ttl
return true
}
}
Session Tests
// tests/auth/session.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { SessionManager } from '../../src/auth/session'
describe('SessionManager', () => {
let manager: SessionManager
beforeEach(() => {
manager = new SessionManager(1000 * 60 * 30)
})
it('should create a session and return a session ID', () => {
const sessionId = manager.createSession('user-1')
expect(sessionId).toBeTypeOf('string')
expect(sessionId.length).toBeGreaterThan(0)
})
it('should retrieve a valid session', () => {
const sessionId = manager.createSession('user-1')
const session = manager.getSession(sessionId)
expect(session).not.toBeNull()
expect(session?.userId).toBe('user-1')
})
it('should return null for a non-existent session', () => {
expect(manager.getSession('does-not-exist')).toBeNull()
})
it('should destroy a session', () => {
const sessionId = manager.createSession('user-1')
expect(manager.destroySession(sessionId)).toBe(true)
expect(manager.getSession(sessionId)).toBeNull()
})
it('should expire sessions after the TTL', () => {
vi.useFakeTimers()
const shortTtlManager = new SessionManager(1000)
const sessionId = shortTtlManager.createSession('user-1')
vi.advanceTimersByTime(1500)
expect(shortTtlManager.getSession(sessionId)).toBeNull()
vi.useRealTimers()
})
it('should refresh a session expiration', () => {
vi.useFakeTimers()
const shortTtlManager = new SessionManager(1000)
const sessionId = shortTtlManager.createSession('user-1')
vi.advanceTimersByTime(800)
shortTtlManager.refreshSession(sessionId)
vi.advanceTimersByTime(800)
expect(shortTtlManager.getSession(sessionId)).not.toBeNull()
vi.useRealTimers()
})
it('should generate unique session IDs', () => {
const id1 = manager.createSession('user-1')
const id2 = manager.createSession('user-1')
expect(id1).not.toBe(id2)
})
})
Testing Cookie Behavior
When testing session middleware that sets cookies, you'll want to verify the cookie attributes that affect security:
// tests/auth/cookie.test.ts
import { describe, it, expect, vi } from 'vitest'
import express from 'express'
import request from 'supertest'
import { SessionManager } from '../../src/auth/session'
const app = express()
const sessions = new SessionManager()
app.use(express.json())
app.post('/login', (req, res) => {
const sessionId = sessions.createSession(req.body.userId)
res.cookie('sessionId', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 1000 * 60 * 30,
})
res.json({ ok: true })
})
app.get('/me', (req, res) => {
const sessionId = req.cookies?.sessionId
if (!sessionId) return res.status(401).json({ error: 'No session' })
const session = sessions.getSession(sessionId)
if (!session) return res.status(401).json({ error: 'Invalid session' })
res.json({ userId: session.userId })
})
describe('Cookie-based session flow', () => {
it('should set a secure httpOnly cookie on login', async () => {
const res = await request(app).post('/login').send({ userId: '42' })
expect(res.status).toBe(200)
const cookieHeader = res.headers['set-cookie']?.[0] || ''
expect(cookieHeader).toContain('HttpOnly')
expect(cookieHeader).toContain('Secure')
expect(cookieHeader).toContain('SameSite=Strict')
})
})
Testing OAuth Integration
OAuth flows involve redirects to third-party providers, callback handling, and token exchange. These are inherently integration-heavy and require careful mocking of external HTTP calls.
OAuth Client Implementation
// src/auth/oauth.ts
export interface OAuthConfig {
clientId: string
clientSecret: string
redirectUri: string
authUrl: string
tokenUrl: string
userInfoUrl: string
scopes: string[]
}
export class OAuthClient {
constructor(private config: OAuthConfig) {}
getAuthorizationUrl(state: string): string {
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: 'code',
scope: this.config.scopes.join(' '),
state,
})
return `${this.config.authUrl}?${params.toString()}`
}
async exchangeCodeForToken(code: string): Promise<{ access_token: string; token_type: string }> {
const res = await fetch(this.config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.config.redirectUri,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
})
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`)
return res.json()
}
async getUserInfo(accessToken: string): Promise<{ id: string; email: string; name: string }> {
const res = await fetch(this.config.userInfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) throw new Error(`Failed to fetch user info: ${res.status}`)
return res.json()
}
}
OAuth Tests with Mocked Fetch
// tests/auth/oauth.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { OAuthClient, OAuthConfig } from '../../src/auth/oauth'
const config: OAuthConfig = {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
redirectUri: 'http://localhost:3000/callback',
authUrl: 'https://provider.example.com/auth',
tokenUrl: 'https://provider.example.com/token',
userInfoUrl: 'https://provider.example.com/me',
scopes: ['openid', 'email', 'profile'],
}
describe('OAuthClient', () => {
let client: OAuthClient
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
client = new OAuthClient(config)
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('getAuthorizationUrl', () => {
it('should build a valid authorization URL', () => {
const url = new URL(client.getAuthorizationUrl('random-state'))
expect(url.origin).toBe('https://provider.example.com')
expect(url.pathname).toBe('/auth')
expect(url.searchParams.get('client_id')).toBe('test-client-id')
expect(url.searchParams.get('redirect_uri')).toBe('http://localhost:3000/callback')
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('scope')).toBe('openid email profile')
expect(url.searchParams.get('state')).toBe('random-state')
})
})
describe('exchangeCodeForToken', () => {
it('should exchange an authorization code for an access token', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ access_token: 'token-123', token_type: 'Bearer' }),
})
const result = await client.exchangeCodeForToken('auth-code')
expect(result.access_token).toBe('token-123')
expect(result.token_type).toBe('Bearer')
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('https://provider.example.com/token')
expect(options.method).toBe('POST')
const body = new URLSearchParams(options.body)
expect(body.get('code')).toBe('auth-code')
expect(body.get('client_secret')).toBe('test-client-secret')
})
it('should throw when the provider returns an error', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 400 })
await expect(client.exchangeCodeForToken('bad-code')).rejects.toThrow(
'Token exchange failed: 400'
)
})
})
describe('getUserInfo', () => {
it('should fetch user info with the access token', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'provider-1', email: 'user@example.com', name: 'Test User' }),
})
const user = await client.getUserInfo('token-123')
expect(user.id).toBe('provider-1')
expect(user.email).toBe('user@example.com')
const [url, options] = fetchMock.mock.calls[0]
expect(options.headers.Authorization).toBe('Bearer token-123')
})
it('should throw when the user info endpoint fails', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 401 })
await expect(client.getUserInfo('expired-token')).rejects.toThrow(
'Failed to fetch user info: 401'
)
})
})
})
Testing the OAuth Callback Handler
The callback handler ties everything together: it validates the state parameter, exchanges the code, fetches user info, and creates a local session. Here's how to test it end-to-end with mocks:
// src/auth/callback.ts
import { OAuthClient } from './oauth'
import { SessionManager } from './session'
export async function handleOAuthCallback(
code: string,
state: string,
expectedState: string,
oauth: OAuthClient,
sessions: SessionManager
): Promise<{ sessionId: string; user: { id: string; email: string; name: string } }> {
if (state !== expectedState) {
throw new Error('State mismatch - possible CSRF attack')
}
const tokenResult = await oauth.exchangeCodeForToken(code)
const user = await oauth.getUserInfo(tokenResult.access_token)
const sessionId = sessions.createSession(user.id)
return { sessionId, user }
}
// tests/auth/callback.test.ts
import { describe, it, expect, vi } from 'vitest'
import { handleOAuthCallback } from '../../src/auth/callback'
import { OAuthClient } from '../../src/auth/oauth'
import { SessionManager } from '../../src/auth/session'
describe('handleOAuthCallback', () => {
it('should reject a state mismatch', async () => {
const oauth = new OAuthClient({} as any)
const sessions = new SessionManager()
await expect(
handleOAuthCallback('code', 'wrong-state', 'expected-state', oauth, sessions)
).rejects.toThrow('State mismatch')
})
it('should complete the full OAuth flow and create a session', async () => {
const sessions = new SessionManager()
const oauth = {
exchangeCodeForToken: vi.fn().mockResolvedValue({ access_token: 'tok', token_type: 'Bearer' }),
getUserInfo: vi.fn().mockResolvedValue({ id: 'p-1', email: 'u@e.com', name: 'U' }),
} as unknown as OAuthClient
const result = await handleOAuthCallback('code', 'state-123', 'state-123', oauth, sessions)
expect(oauth.exchangeCodeForToken).toHaveBeenCalledWith('code')
expect(oauth.getUserInfo).toHaveBeenCalledWith('tok')
expect(result.user.id).toBe('p-1')
expect(result.sessionId).toBeTypeOf('string')
expect(sessions.getSession(result.sessionId)?.userId).toBe('p-1')
})
})
Best Practices for Authentication Testing
1. Never Use Real Secrets in Tests
Always use test-specific secrets and credentials. Store them in a .env.test file or define them directly in the setup file. Never commit production secrets to your repository.
2. Test Both Happy and Unhappy Paths
It's tempting to only test successful authentication, but the failure paths are where security vulnerabilities hide. Always test expired tokens, tampered signatures, missing headers, and invalid states.
3. Use Fake Timers for Expiration Logic
Token and session expiration tests should never rely on real time delays. Use Vitest's vi.useFakeTimers() and vi.advanceTimersByTime() to deterministically test time-based logic.
4. Mock External HTTP Calls
OAuth providers should never be called during tests. Use vi.stubGlobal('fetch', mock) or libraries like msw to intercept and mock HTTP requests. This keeps tests fast, deterministic, and free of rate limits.
5. Test State Parameter Validation
The OAuth state parameter protects against CSRF attacks. Always include a test that verifies a mismatched state is rejected. This is a common security gap that's easy to overlook.
6. Isolate Test State
Use beforeEach to reset session stores, clear mocks, and restore timers. Shared mutable state between tests leads to flaky, hard-to-debug failures.
7. Verify Cookie Security Attributes
When testing session cookies, assert that HttpOnly, Secure, and SameSite attributes are set correctly. These attributes prevent XSS-based session theft and CSRF attacks.
8. Measure Coverage on Auth Modules
Configure Vitest coverage to specifically include your auth directories. Aim for 100% branch coverage on token verification and session validation logic, as partial coverage can hide critical edge cases.
Conclusion
Testing authentication with Vitest is straightforward once you understand the patterns for mocking tokens, sessions, and external OAuth providers. By combining Vitest's fast execution with thorough test cases covering both success and failure paths, you can build a robust auth test suite that catches regressions before they reach production. Remember to keep secrets out of your test environment, mock all external calls, use fake timers for expiration logic, and verify security-critical attributes like cookie flags and state parameters. With these practices in place, your authentication layer becomes one of the most reliable parts of your application rather than a source of anxiety.