Complete Guide to JSON Web Tokens (JWTs)
JWT structure: header, payload, signature
A JWT is three base64url-encoded JSON objects separated by dots. The Header contains the token type ('JWT') and the signing algorithm ('HS256', 'RS256', etc.). The Payload contains claims — name/value pairs about the user or session. The Signature is computed by the issuer: HMAC-SHA256(base64url(header) + '.' + base64url(payload), secret). The complete token is header.payload.signature, all base64url-encoded. Dots are used as separators because they are safe in URLs and cannot appear in base64url-encoded data.
Authentication flow with JWTs
The standard flow: (1) User submits credentials to POST /login. (2) Server verifies credentials, creates a JWT payload with user ID and roles, signs it with the secret key, returns the token. (3) Client stores the token (localStorage or httpOnly cookie). (4) Client sends the token in the Authorization header: 'Bearer {token}' on subsequent requests. (5) Server validates the signature, checks expiry, reads claims — no database lookup required. (6) When the token expires, the client uses a refresh token to get a new access token. The key advantage: the server is stateless — it does not need to store sessions.
Common JWT vulnerabilities
Algorithm confusion: some libraries accept tokens with alg: none or allow the client to specify the algorithm. Fix: always specify expected algorithms explicitly. Key confusion: HS256 and RS256 use different key types. A library that accepts both may be confused into using the RS256 public key as an HMAC secret. Secret exposure: weak or hardcoded secrets can be brute-forced. Use cryptographically random secrets of at least 256 bits. Missing expiry: tokens without exp claim never expire — if stolen, they grant permanent access. Token leakage: JWTs in query strings appear in server logs and browser history — always use the Authorization header. Overly permissive CORS: if your API accepts tokens from any origin, attackers on other pages can access it.
JWT libraries and validation in code
Node.js: jsonwebtoken — jwt.sign(payload, secret), jwt.verify(token, secret). Python: PyJWT — jwt.encode(payload, key), jwt.decode(token, key, algorithms=['HS256']). Java: nimbus-jose-jwt, auth0/java-jwt. Go: golang-jwt/jwt. PHP: firebase/php-jwt. Ruby: ruby-jwt. C#: System.IdentityModel.Tokens.Jwt. All libraries follow the same pattern: specify the algorithm and key explicitly when decoding. Never use a decode-only function without signature verification in production code — decoding without verification bypasses the security entirely.
JWKS: JSON Web Key Sets for RS256
When using RS256 (asymmetric), the public key must be distributed to all services that verify tokens. JWKS (JSON Web Key Sets) is the standard for publishing public keys at a well-known URL (typically /.well-known/jwks.json). Each key has a kid (key ID) field. The JWT header includes the kid so verifiers know which key to use. This enables key rotation: add a new key to JWKS, start signing with it, remove the old key after old tokens expire. Auth providers (Auth0, Okta, Cognito, Keycloak) publish JWKS endpoints automatically. Services fetch and cache the public keys periodically.
Storing JWTs securely in the browser
Two options: localStorage and httpOnly cookies. localStorage is accessible to JavaScript on the page — vulnerable to XSS attacks where injected script steals the token. httpOnly cookies cannot be accessed by JavaScript — immune to XSS — but vulnerable to CSRF attacks (though CSRF mitigations are well-understood). For most applications, httpOnly cookies with SameSite=Strict or SameSite=Lax and CSRF tokens is the more secure option. localStorage is simpler to implement and acceptable for low-risk applications with strong Content Security Policy headers. The critical decision: what is the risk profile of a stolen token and what attacks are you defending against?
Token revocation strategies
JWTs are stateless — once issued, they are valid until expiry with no database lookup. This makes revocation hard. Strategies: (1) Short expiry — 15 minutes or less — minimizes the window a stolen token is useful. Requires refresh tokens for practical sessions. (2) Blocklist — store revoked JWT IDs (jti claim) in Redis with TTL matching the token's remaining lifetime. Adds a cache lookup to every request but allows immediate revocation. (3) Rotation — issue a new token on every request and invalidate the previous one. Requires state but provides single-use semantics. (4) User version — store a version number per user; include it in the JWT; revoke all tokens by incrementing the version. Each strategy trades statefulness against revocation immediacy.