JWT in depth: three segments, HS256 vs RS256, common pitfalls, safe storage
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.payload using 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 exp is in the past
- Whether aud matches this service (prevents token reuse across services)
- Whether iss is 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: none header β 401 (NOT 200)
- Token issued for service A, sent to service B β 401 (aud enforcement)
- HS256 token signed with the RS256 public key β 401 (algorithm confusion defense)
- Payload role changed from user to admin (signature unchanged) β 401
The [JWT decoder / generator tool](/en/tools/jwt) can manually craft every one of those cases.
Try it: use the [JWT tool](/en/tools/jwt)'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.