JWTs are the most widely adopted stateless authentication standard of the past decade — and one of the most frequently misused protocols on the OWASP Top 10. From a QA angle, understanding the structure is what turns vague "test auth" tickets into concrete security test cases.
What the three segments are, and why
JWT splits on . into three Base64URL-encoded segments:
- Header:
{"alg":"HS256","typ":"JWT"}— algorithm and type - Payload:
{"sub":"123","exp":1700000000,"role":"admin"}— claims (subject, expiry, custom fields) - Signature: the signed digest of
header.payloadusing the algorithm in the header, with a secret (HS256) or private key (RS256)
Header and payload are not encrypted — only encoded. Anyone can Base64-decode them and read the contents. Security comes from the signature: the verifier recomputes the signature with its secret / public key, compares with the token's signature, and rejects anything that doesn't match — so any tampering with the payload immediately fails.
HS256 vs RS256 vs ES256: which to use when
- HS256 (symmetric): fast and simple, but signer and verifier share the same secret. Fits inside a single service (API gateway → your own backend).
- RS256 (asymmetric RSA): sign with a private key, verify with a public key. Fits one-to-many (auth server issues tokens, many microservices verify). Public keys can be shared safely.
- ES256 (asymmetric ECDSA): same model as RS256 but signatures are shorter and signing is ~80% faster. Recommended for new projects.
WebCrypto and Node's standard library support all three natively.
Three vulnerabilities you must test
1. The alg: none attack: some older libraries accept {"alg":"none"} meaning "skip signature verification". Attackers tamper with the payload, leave the signature empty, and the server lets them through.
2. Algorithm confusion (RS256 → HS256): the server expects RS256, but the attacker rewrites the header to alg: HS256 and signs with the public key as the HMAC secret. Libraries that don't enforce algorithm consistency accept it — and the public key is, well, public.
3. Missing exp / aud validation: most QA teams only check the signature is well-formed but forget:
- Whether
expis in the past - Whether
audmatches this service (prevents token reuse across services) - Whether
issis the trusted issuer
Each of those needs its own negative test case in your suite.
Where to store tokens client-side (pick one risk)
- localStorage: easiest, but a single XSS leaks every token (malicious scripts read it directly).
- httpOnly + Secure + SameSite=Strict cookies: immune to XSS, but vulnerable to CSRF unless you also use a CSRF token or SameSite=Lax with double-submit.
- In-memory (React state): most secure, but refresh wipes it — you need a refresh token (in an httpOnly cookie) to mint a new one.
The industry default: access token in memory + refresh token in httpOnly cookie. This combination has reasonable defenses against both XSS and CSRF.
JWT test checklist for QA
Positive:
- Valid token → 200
- The role / scope in the payload actually affects what the API returns
Negative (skipping these means your system has a real hole):
- Expired token → 401
- Tampered signature → 401
alg: noneheader → 401 (NOT 200)- Token issued for service A, sent to service B → 401 (
audenforcement) - HS256 token signed with the RS256 public key → 401 (algorithm confusion defense)
- Payload
rolechanged fromusertoadmin(signature unchanged) → 401
The JWT decoder / generator tool can manually craft every one of those cases.
Try it: use the JWT tool's Sign mode to mint a token with alg: none or with exp in the past, then send it to your API and confirm it gets rejected.