JWT Security Testing for QA Engineers
A practical guide to testing JSON Web Token security in your APIs. Learn how to decode JWTs, test for common vulnerabilities like algorithm confusion and.
JSON Web Tokens are the dominant authentication mechanism in modern APIs. They power login flows, authorisation checks, and service-to-service communication across virtually every production system built in the last decade. And because they're so ubiquitous, they're also one of the most commonly misconfigured security components in those same systems.
As a QA engineer, you don't need to be a security researcher to test JWT implementations effectively. You need to understand the structure of a token, know which claims matter, and be able to write assertions that catch the most common implementation mistakes before they reach production.
What a JWT actually is
A JWT is three Base64URL-encoded strings joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwNDEyMzQ1NiwiZXhwIjoxNzA0MjA5ODU2fQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Part 1 — Header: algorithm and token type.
JSON1{ 2 "alg": "HS256", 3 "typ": "JWT" 4}
Part 2 — Payload: the claims. These are statements about the user or session.
JSON1{ 2 "sub": "user_123", 3 "role": "admin", 4 "iat": 1704123456, 5 "exp": 1704209856 6}
Part 3 — Signature: a cryptographic signature that verifies the header and payload haven't been tampered with.
The key insight for QA engineers: the header and payload are encoded, not encrypted. Anyone can read them. The signature only prevents modification — it doesn't hide the content.
Decoding JWTs during testing
Use the JWT Decoder tool to inspect a token without any setup. Paste the token from a login response and immediately see:
- Which algorithm is being used (
algclaim) - When the token expires (
expclaim as a human-readable timestamp) - What role or permissions are embedded (
role,scope,permissionsclaims) - The subject identifier (
subclaim)
In automated tests, decode the token programmatically:
TYPESCRIPT1function decodeJwt(token: string) { 2 const [headerB64, payloadB64] = token.split('.') 3 const decode = (s: string) => JSON.parse( 4 Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8') 5 ) 6 return { header: decode(headerB64), payload: decode(payloadB64) } 7} 8 9// In your Playwright test 10const response = await request.post('/api/auth/login', { 11 data: { email: 'user@example.com', password: 'password123' } 12}) 13const { token } = await response.json() 14const { header, payload } = decodeJwt(token) 15 16// Assert the correct claims are present 17expect(header.alg).toBe('RS256') // Should NOT be 'none' or 'HS256' for asymmetric APIs 18expect(payload.sub).toBe('user_123') 19expect(payload.role).toBe('member') 20expect(payload.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
The standard claims to assert
| Claim | Type | What to test |
|---|---|---|
sub | string | Matches the authenticated user's ID |
iss | string | Matches your expected issuer URL |
aud | string/array | Matches your application's client ID |
exp | Unix timestamp | Is in the future; is within expected window |
iat | Unix timestamp | Is in the past (not a future timestamp) |
nbf | Unix timestamp | If present, is not in the future |
jti | string | Is unique per token (prevents replay attacks) |
TYPESCRIPT1const now = Math.floor(Date.now() / 1000) 2const { payload } = decodeJwt(token) 3 4// Core validity assertions 5expect(payload.iss).toBe('https://auth.yourapp.com') 6expect(payload.aud).toContain('yourapp-client') 7expect(payload.exp).toBeGreaterThan(now) 8expect(payload.iat).toBeLessThanOrEqual(now) 9 10// Expiry window — token should last at most 1 hour 11const windowSeconds = 3600 12expect(payload.exp - payload.iat).toBeLessThanOrEqual(windowSeconds)
Security vulnerabilities to test for
1. Algorithm none acceptance
One of the most severe JWT vulnerabilities: some libraries, if misconfigured, accept tokens with "alg": "none" and no signature. An attacker can forge arbitrary claims.
Test: Craft a token with "alg": "none" and an empty signature. The server must reject it with 401.
TYPESCRIPT1function craftNoneAlgToken(payload: object): string { 2 const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url') 3 const body = Buffer.from(JSON.stringify(payload)).toString('base64url') 4 return `${header}.${body}.` // empty signature 5} 6 7const forgedToken = craftNoneAlgToken({ sub: 'admin', role: 'superadmin', exp: 9999999999 }) 8const response = await request.get('/api/admin/users', { 9 headers: { Authorization: `Bearer ${forgedToken}` } 10}) 11expect(response.status()).toBe(401)
2. Algorithm confusion (RS256 → HS256)
If a server uses RS256 (asymmetric), an attacker might switch the algorithm to HS256 (symmetric) and sign the token with the public key (which is openly available). A misconfigured library may then verify the signature using that same public key as the HMAC secret — accepting the forged token.
Test: Verify that the server consistently rejects tokens declaring an unexpected algorithm:
TYPESCRIPT1// Get a valid RS256 token 2const validToken = await getAuthToken() 3const { payload } = decodeJwt(validToken) 4 5// Forge an HS256 token with the same payload 6const forgedHeader = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') 7const forgedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') 8const forgedSignature = 'invalidsignature' 9const forgedToken = `${forgedHeader}.${forgedPayload}.${forgedSignature}` 10 11const response = await request.get('/api/me', { 12 headers: { Authorization: `Bearer ${forgedToken}` } 13}) 14expect(response.status()).toBe(401)
3. Expired token acceptance
Some APIs fail to reject expired tokens in non-production environments. Always test:
TYPESCRIPT1// Use a real token that has expired (from a previous session or a short-lived test token) 2const expiredToken = 'eyJ...' // a token where exp is in the past 3 4const response = await request.get('/api/me', { 5 headers: { Authorization: `Bearer ${expiredToken}` } 6}) 7expect(response.status()).toBe(401) 8 9const body = await response.json() 10// Error should indicate the token is expired, not just "unauthorized" 11expect(JSON.stringify(body)).toMatch(/expired|exp/i)
4. Privilege escalation via payload modification
A user-level token should never be accepted as an admin token, even if the payload is manually modified. This tests that your signature verification actually happens:
TYPESCRIPT1function tamperPayload(token: string, claims: object): string { 2 const [header, , sig] = token.split('.') 3 const newPayload = Buffer.from(JSON.stringify(claims)).toString('base64url') 4 return `${header}.${newPayload}.${sig}` // invalid signature 5} 6 7const userToken = await getAuthToken('member@example.com') 8const { payload } = decodeJwt(userToken) 9const tamperedToken = tamperPayload(userToken, { ...payload, role: 'admin' }) 10 11const response = await request.get('/api/admin/dashboard', { 12 headers: { Authorization: `Bearer ${tamperedToken}` } 13}) 14expect(response.status()).toBe(401)
5. Token reuse after logout
After a user logs out, their token should be invalidated server-side (if using a token blocklist or short-lived tokens).
TYPESCRIPT1// Login, capture token, logout, then try to use the token 2const loginRes = await request.post('/api/auth/login', { data: credentials }) 3const { token } = await loginRes.json() 4 5await request.post('/api/auth/logout', { 6 headers: { Authorization: `Bearer ${token}` } 7}) 8 9// Token should now be rejected 10const afterLogout = await request.get('/api/me', { 11 headers: { Authorization: `Bearer ${token}` } 12}) 13expect(afterLogout.status()).toBe(401)
JWT assertions in a test helper
Rather than repeating JWT assertions across dozens of tests, centralise them:
TYPESCRIPT1// tests/helpers/jwt.ts 2export function assertValidJwt(token: string, expectedRole?: string) { 3 const { header, payload } = decodeJwt(token) 4 const now = Math.floor(Date.now() / 1000) 5 6 // Algorithm 7 expect(['RS256', 'RS384', 'RS512', 'ES256']).toContain(header.alg) 8 expect(header.alg).not.toBe('none') 9 10 // Required claims 11 expect(payload.sub).toBeTruthy() 12 expect(payload.iss).toBe(process.env.JWT_ISSUER) 13 expect(payload.exp).toBeGreaterThan(now) 14 expect(payload.iat).toBeLessThanOrEqual(now) 15 16 // Expiry window — no token should last more than 24 hours 17 expect(payload.exp - payload.iat).toBeLessThanOrEqual(86400) 18 19 // Optional role assertion 20 if (expectedRole) { 21 expect(payload.role).toBe(expectedRole) 22 } 23}
What JWT testing does NOT cover
JWTs are stateless by design. Testing the JWT structure does not verify:
- Authorisation logic — that a user with
role: "member"is actually blocked from admin endpoints. Test access control separately. - Refresh token security — refresh token rotation, reuse detection, and revocation need their own test cases.
- Transport security — tokens should only be transmitted over HTTPS. This is a network/infrastructure concern, not a JWT concern.
- Storage security — tokens stored in
localStorageare vulnerable to XSS; tokens inhttpOnlycookies are not. Test the cookie settings separately.
JWT testing is one layer of authentication testing. It confirms the token structure is correct. It doesn't replace full authorisation testing across all protected endpoints.
Share this article
Follow for more
Follow me on social media for more developer tips, tricks, and tutorials. Let's connect and build something great together!