Back to blog
Test Automation#jwt#security-testing#api-testing#authentication#qa-tools#token-testing

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.

InnovateBits7 min read

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 (alg claim)
  • When the token expires (exp claim as a human-readable timestamp)
  • What role or permissions are embedded (role, scope, permissions claims)
  • The subject identifier (sub claim)

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

ClaimTypeWhat to test
substringMatches the authenticated user's ID
issstringMatches your expected issuer URL
audstring/arrayMatches your application's client ID
expUnix timestampIs in the future; is within expected window
iatUnix timestampIs in the past (not a future timestamp)
nbfUnix timestampIf present, is not in the future
jtistringIs 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 localStorage are vulnerable to XSS; tokens in httpOnly cookies 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.

Free newsletter

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.