Q12 of 37 · API testing
Explain the structure of a JWT and how to test endpoints that use it.
Short answer
Short answer: JWT = base64url(header).base64url(payload).base64url(signature). Header declares the algorithm; payload contains claims (sub, exp, scopes); signature verifies integrity. Test by issuing real tokens via the auth endpoint, asserting expiry and scope behaviours, and confirming tampered tokens are rejected.
Detail
Structure: a JWT is three base64url-encoded segments joined with dots:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTcwMDAwMDAwMH0.signature
- Header —
{ "alg": "HS256", "typ": "JWT" }. Algorithm and type. - Payload — claims. Standard ones:
iss(issuer),sub(subject — usually user id),aud(audience),exp(expiry epoch),iat(issued-at),scope/scpfor OAuth scopes. Custom claims (tenant_id,role) on top. - Signature — HMAC or RSA over header+payload using a server secret. Anyone can decode the token (it's just base64) — only the server can verify the signature.
Important: JWTs are not encrypted by default. Anyone with the token can read the payload. Don't put secrets in claims. Treat JWTs as authenticated, not confidential.
Test patterns:
1. Decode and inspect during test — jsonwebtoken (Node), pyjwt (Python), io.jsonwebtoken:jjwt (Java). Validate the issued token has expected claims:
import jwt from 'jsonwebtoken';
const decoded = jwt.decode(token) as JwtPayload;
expect(decoded.sub).toBe(userId);
expect(decoded.scope).toContain('reports:read');
2. Expiry behaviour — issue a token with a short exp, wait, retry the API, assert 401. Or stub clock if you have access. Never test by editing the token's exp field — the signature breaks, and you're testing tampering, not expiry.
3. Scope-based authorisation — issue tokens with different scopes, confirm endpoints accept/reject correctly:
const readOnly = await getToken({ scope: 'reports:read' });
const res = await request.delete('/reports/42', {
headers: { Authorization: `Bearer ${readOnly}` },
});
expect(res.status()).toBe(403);
4. Tampering — flip a character in the signature; assert 401:
const tampered = token.slice(0, -5) + 'xxxxx';
const res = await request.get('/users/me', {
headers: { Authorization: `Bearer ${tampered}` },
});
expect(res.status()).toBe(401);
5. Algorithm confusion (security regression test) — submit a token signed with none and confirm rejection. CVEs in JWT libraries have shipped because the server accepted alg: none.
Anti-pattern: hardcoding tokens in tests. They expire, they pin you to a specific user/tenant. Always issue fresh tokens via the auth endpoint at test time.
// EXAMPLE
jwt.api.test.ts
import { test, expect, request } from '@playwright/test';
import jwt from 'jsonwebtoken';
test('admin token includes admin scope', async () => {
const ctx = await request.newContext();
const res = await ctx.post('/auth/login', {
data: { email: 'admin@x.com', password: 'secret' },
});
const { token } = await res.json();
const decoded = jwt.decode(token) as { scope: string; exp: number };
expect(decoded.scope.split(' ')).toContain('admin');
expect(decoded.exp * 1000).toBeGreaterThan(Date.now());
});