Q12 of 37 · API testing

Explain the structure of a JWT and how to test endpoints that use it.

API testingMidapijwtauthsecurity

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 / scp for 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());
});

// WHAT INTERVIEWERS LOOK FOR

Three-segment structure with claim names, the security note that JWTs aren't encrypted, and concrete test patterns: decode + assert claims, expiry, scope, tampering. Bonus for alg:none awareness.

// COMMON PITFALL

Hardcoding JWTs in test fixtures — they expire silently and the suite breaks weeks later. Always issue fresh tokens at test time.