JWT Security Testing: What Every QA Engineer Must Know
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 expired token acceptance, and build JWT assertions into your automated test suite.
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.
{
"alg": "HS256",
"typ": "JWT"
}Part 2 — Payload: the claims. These are statements about the user or session.
{
"sub": "user_123",
"role": "admin",
"iat": 1704123456,
"exp": 1704209856
}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:
function decodeJwt(token: string) {
const [headerB64, payloadB64] = token.split('.')
const decode = (s: string) => JSON.parse(
Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
)
return { header: decode(headerB64), payload: decode(payloadB64) }
}
// In your Playwright test
const response = await request.post('/api/auth/login', {
data: { email: 'user@example.com', password: 'password123' }
})
const { token } = await response.json()
const { header, payload } = decodeJwt(token)
// Assert the correct claims are present
expect(header.alg).toBe('RS256') // Should NOT be 'none' or 'HS256' for asymmetric APIs
expect(payload.sub).toBe('user_123')
expect(payload.role).toBe('member')
expect(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) |
const now = Math.floor(Date.now() / 1000)
const { payload } = decodeJwt(token)
// Core validity assertions
expect(payload.iss).toBe('https://auth.yourapp.com')
expect(payload.aud).toContain('yourapp-client')
expect(payload.exp).toBeGreaterThan(now)
expect(payload.iat).toBeLessThanOrEqual(now)
// Expiry window — token should last at most 1 hour
const windowSeconds = 3600
expect(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.
function craftNoneAlgToken(payload: object): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.` // empty signature
}
const forgedToken = craftNoneAlgToken({ sub: 'admin', role: 'superadmin', exp: 9999999999 })
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${forgedToken}` }
})
expect(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:
// Get a valid RS256 token
const validToken = await getAuthToken()
const { payload } = decodeJwt(validToken)
// Forge an HS256 token with the same payload
const forgedHeader = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url')
const forgedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
const forgedSignature = 'invalidsignature'
const forgedToken = `${forgedHeader}.${forgedPayload}.${forgedSignature}`
const response = await request.get('/api/me', {
headers: { Authorization: `Bearer ${forgedToken}` }
})
expect(response.status()).toBe(401)3. Expired token acceptance
Some APIs fail to reject expired tokens in non-production environments. Always test:
// Use a real token that has expired (from a previous session or a short-lived test token)
const expiredToken = 'eyJ...' // a token where exp is in the past
const response = await request.get('/api/me', {
headers: { Authorization: `Bearer ${expiredToken}` }
})
expect(response.status()).toBe(401)
const body = await response.json()
// Error should indicate the token is expired, not just "unauthorized"
expect(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:
function tamperPayload(token: string, claims: object): string {
const [header, , sig] = token.split('.')
const newPayload = Buffer.from(JSON.stringify(claims)).toString('base64url')
return `${header}.${newPayload}.${sig}` // invalid signature
}
const userToken = await getAuthToken('member@example.com')
const { payload } = decodeJwt(userToken)
const tamperedToken = tamperPayload(userToken, { ...payload, role: 'admin' })
const response = await request.get('/api/admin/dashboard', {
headers: { Authorization: `Bearer ${tamperedToken}` }
})
expect(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).
// Login, capture token, logout, then try to use the token
const loginRes = await request.post('/api/auth/login', { data: credentials })
const { token } = await loginRes.json()
await request.post('/api/auth/logout', {
headers: { Authorization: `Bearer ${token}` }
})
// Token should now be rejected
const afterLogout = await request.get('/api/me', {
headers: { Authorization: `Bearer ${token}` }
})
expect(afterLogout.status()).toBe(401)JWT assertions in a test helper
Rather than repeating JWT assertions across dozens of tests, centralise them:
// tests/helpers/jwt.ts
export function assertValidJwt(token: string, expectedRole?: string) {
const { header, payload } = decodeJwt(token)
const now = Math.floor(Date.now() / 1000)
// Algorithm
expect(['RS256', 'RS384', 'RS512', 'ES256']).toContain(header.alg)
expect(header.alg).not.toBe('none')
// Required claims
expect(payload.sub).toBeTruthy()
expect(payload.iss).toBe(process.env.JWT_ISSUER)
expect(payload.exp).toBeGreaterThan(now)
expect(payload.iat).toBeLessThanOrEqual(now)
// Expiry window — no token should last more than 24 hours
expect(payload.exp - payload.iat).toBeLessThanOrEqual(86400)
// Optional role assertion
if (expectedRole) {
expect(payload.role).toBe(expectedRole)
}
}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.
Stay ahead in AI-driven QA
Get practical tutorials on test automation, AI testing, and quality engineering — straight to your inbox. No spam, unsubscribe any time.