JWT tokens
Deep dive on JWT: structure, signing algorithms, the alg confusion attacks, refresh tokens, revocation strategies, and when not to use JWTs at all.
What JWT Actually Is
A JWT (JSON Web Token, RFC 7519) is a compact, URL-safe representation of claims, secured by a signature. The standard is built on top of JWS (JSON Web Signature, RFC 7515).
A JWT is three parts separated by dots, each base64url-encoded.
- Header: JSON object with
alg(algorithm) andtyp(always "JWT"). - Payload: JSON object with claims. The data.
- Signature: cryptographic signature over
base64url(header) + "." + base64url(payload).
Decoded example.
Header: {"alg":"HS256","typ":"JWT"}
Payload: {"sub":"user_123","exp":1700000000,"role":"admin"}
Signature: HMAC-SHA256(secret, "<b64header>.<b64payload>")
Anyone can decode the payload. The signature only verifies it was not tampered with. JWT is NOT encryption. There is a separate spec (JWE, RFC 7516) for encrypted tokens, but it is rarely used because most JWTs do not contain secrets, just claims.
Signing Algorithms
The alg header field picks the algorithm. Three families.
- HSxxx (HMAC): symmetric. HS256 = HMAC-SHA256. One secret, used to both sign and verify. Fast (microseconds), small. Use when one service issues and verifies the token.
- RSxxx (RSA): asymmetric. RS256 = RSA-PKCS1-v1_5 + SHA-256. Private key signs, public key verifies. Use when many services need to verify tokens from one issuer.
- ESxxx (ECDSA): asymmetric, smaller. ES256 = ECDSA P-256 + SHA-256. Same use case as RS but smaller signatures.
- EdDSA: Ed25519 signatures. The modern choice. Used by some OAuth providers, not yet universal.
A common mistake is picking RS256 by default. It produces 256-byte signatures, which makes tokens fat. ES256 produces 64 bytes for equivalent security. Use ES256 unless you need RS256 for interop.
The alg Confusion Attacks
JWT has a track record of library bugs around the alg field.
The "alg: none" attack. The spec allows alg: none for unsigned tokens. Some old libraries treated alg: none as "skip verification" without the developer opting in. An attacker takes a real JWT, changes the header to {"alg":"none"}, strips the signature (just leaves the trailing dot), and many libraries accept it. Forge any payload you want.
The "alg confusion" or "key confusion" attack. If a server expects RS256 (asymmetric), an attacker takes the public key (which is, well, public) and signs a token with alg: HS256 using the public key bytes as the HMAC secret. Some libraries naively use the same key-lookup code regardless of alg, so they fetch the RSA public key and try to verify the HS256 signature with it, which succeeds because the attacker signed it with that exact bytes.
Defenses.
- Hardcode the expected algorithm in your verify call. Do not trust the
algheader. - Use a modern library that does this for you.
jose(Node),python-jose,golang-jwt. Avoid anything that has not been updated in 3 years. - Reject
alg: nonealways.
Claims You Should Know
RFC 7519 defines registered claims. Use these names, not custom ones.
| Claim | Meaning | Required? |
|---|---|---|
| iss | Issuer | Recommended |
| sub | Subject (usually user ID) | Recommended |
| aud | Audience (which service) | Recommended for multi-service |
| exp | Expiration (Unix timestamp) | Always |
| nbf | Not before | Optional |
| iat | Issued at | Recommended |
| jti | Unique JWT ID | For revocation |
Custom claims should be namespaced. https://yourapp.com/role not just role. This avoids future conflicts when the spec adds new registered claims.
Access Tokens and Refresh Tokens
Short-lived JWTs are great for access. Long-lived JWTs are dangerous because you cannot revoke them. The pattern is.
- Access token: JWT, 15 minute expiration. Used on every API call. Stateless verification.
- Refresh token: opaque random string, stored in DB. 30 day expiration. Used to mint new access tokens.
When the access token expires, the client exchanges the refresh token for a new access token. The auth server checks the refresh token against the DB, optionally rotates it (one-time use), and issues a new JWT.
If the refresh token leaks, you revoke the DB row and the attacker is out. If the access JWT leaks, you wait at most 15 minutes for it to expire (or use a blocklist).
Revocation
The big JWT tradeoff: you cannot revoke a token by deleting a row. The verifier does not need to talk to the database, so it does not see the deletion. Three approaches.
- Short expiration plus refresh tokens. Most common. Acceptable for most apps.
- Token blocklist. Add the
jtito a Redis set on logout. Verifier checks the blocklist on every request. You lose the "no DB hit" property but get revocation. Worth it for sensitive apps. - Token versioning. Store a
tokenVersionon the user. Include it in the JWT astv. When you want to revoke all tokens for a user (password change), bump the user'stokenVersion. Verifier compares.
If you find yourself wanting fine-grained revocation a lot, JWT may not be the right tool. Session cookies with a Redis session store give you instant revocation and similar performance.
When Not to Use JWT
JWT became cargo-culted around 2015 and people use it for things where a session cookie would be better.
- Single-region monolith with a session store: just use a session cookie. Faster, revocable, no alg-confusion bugs, simpler.
- Storing in localStorage: makes you vulnerable to XSS. The XSS attacker reads localStorage and steals the JWT. Use httpOnly Secure cookies if you can.
- Long-lived tokens: JWT was not designed for "remember me for 6 months." Use a refresh token.
The right use case for JWT.
- Federated systems: one auth server signs, many resource servers verify without coordinating.
- Stateless serverless functions where you do not want every Lambda invocation to query a session DB.
- Cross-domain SSO via OAuth/OIDC, which uses JWT for ID tokens.
Storage on the Client
Three options, each with tradeoffs.
| Storage | XSS risk | CSRF risk | Sent automatically |
|---|---|---|---|
| localStorage | High | None | No, must attach manually |
| httpOnly cookie | None | Yes | Yes |
| Memory only (JS variable) | High (if XSS during session) | None | No |
The OWASP guidance is httpOnly Secure SameSite=Lax cookie with CSRF tokens for state-changing requests. localStorage is convenient but loses to XSS, which is a much more common attack than CSRF on modern apps with proper SameSite cookies.
Performance Notes
JWT verification is fast but not free.
- HS256 verify: ~10 microseconds
- RS256 verify: ~30 microseconds (RSA verify is fast)
- ES256 verify: ~150 microseconds (ECDSA verify is slower than RSA verify)
For 10k req/sec per server, JWT verification is a rounding error compared to the actual work. If you are doing 1M req/sec per server, switch to a session ID lookup in a fast cache, which is even cheaper.
JWT size is the bigger concern. A typical JWT is 500-1500 bytes. On every request that is real bandwidth. Keep payloads minimal: user ID, exp, role. Do not stuff entire user profiles in the token.
Common Pitfalls
- Putting passwords or API keys in the payload. The payload is base64url, anyone reads it.
- Not setting
exp. Tokens valid forever. - Long expirations to avoid the refresh dance. Then no revocation when an account is compromised.
- Trusting the
algheader in the token itself for verification. - Storing JWTs in localStorage and getting XSS'd.
- Using HS256 with a weak secret like "secret" or the framework default.
Interview Soundbites
- "JWT is signed not encrypted. Anyone can read the payload. The signature stops tampering."
- "I default to ES256 over RS256. Smaller signatures, equivalent security."
- "Short-lived access tokens plus a refresh token in a DB row. Tradeoff: 15-minute window where a stolen access token still works."
- "If I need instant revocation everywhere, I use a session cookie with a Redis store, not JWT."
Learn more
- DocsRFC 7519: JWTIETF
- Docs
- Docs
- Article