Dev Tools
Back to articles·8 min

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 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.

Paired tool
JWT decoder / generator
Open the tool