1Why should multi-service systems use RS256 instead of HS256 for JWT signing?
JSON Web Tokens (JWTs) are self-contained, signed tokens that carry claims between parties. Designing tokens correctly -- choosing signing algorithms, claim sets, lifetimes, and rotation strategies -- is critical for security and scalability in distributed systems.
JSON Web Tokens (JWTs, RFC 7519) are the dominant token format for API authentication in distributed systems. A JWT is a compact, URL-safe string consisting of three parts separated by dots: the header (specifies the signing algorithm), the payload (contains claims), and the signature (proves the token was not tampered with). JWTs are 'self-contained' because the payload includes all the information needed to verify the token and identify the caller, without requiring a database lookup.
The header specifies the algorithm (alg) and token type (typ). The most important design decision is the signing algorithm. HS256 (HMAC-SHA256) uses a shared secret: both the issuer and verifier must know the same key. This is simple but problematic in microservice architectures because every service that verifies tokens needs the secret, increasing the blast radius of a compromise. RS256 (RSA-SHA256) and ES256 (ECDSA-SHA256) use asymmetric keys: the authorization server signs with a private key, and any service verifies with the corresponding public key. This means verification keys can be distributed freely (via JWKS endpoints) without compromising the signing key.
The payload contains claims -- key-value pairs that carry information about the token. Standard claims include: iss (issuer), sub (subject/user ID), aud (audience -- who should accept this token), exp (expiry timestamp), iat (issued at), and jti (unique token ID for revocation). Custom claims add application-specific data: tenant_id, email, roles. A critical design principle is to keep the payload minimal. Every byte in the JWT is transmitted with every HTTP request (in the Authorization header), so a 4KB JWT adds 4KB of overhead to every API call. Include only identity claims; look up permissions at authorization time.
Key rotation is essential but often neglected. Signing keys should be rotated periodically (every 90 days) and immediately if compromised. JWKS (JSON Web Key Sets, RFC 7517) solves this: the authorization server publishes its current and recent public keys at a well-known URL (/.well-known/jwks.json). Verifying services fetch and cache this key set, matching tokens by their kid (key ID) header. When a new key is added, old keys remain in the JWKS for a transition period to allow in-flight tokens signed with the old key to be verified.
JWTs are signed, not encrypted. Anyone who intercepts a JWT can decode the payload (it is just Base64URL). Never include sensitive data (passwords, credit card numbers, internal IDs that reveal business logic) in JWT claims. If confidentiality is required, use JWE (JSON Web Encryption), though this is rarely needed for authentication tokens -- HTTPS already protects tokens in transit.
Anatomy of a JWT
A JWT has three parts: Header = {"alg": "RS256", "kid": "key-2024-01", "typ": "JWT"} tells verifiers which algorithm and key to use. Payload = {"iss": "https://auth.example.com", "sub": "user_abc123", "aud": "orders-api", "exp": 1705312800, "iat": 1705311900, "tenant_id": "acme"} identifies the user and token validity. Signature = RS256(base64(header) + '.' + base64(payload), private_key) proves the token was issued by the authorization server and has not been modified. The three parts are Base64URL-encoded and joined with dots: eyJhbGci...eyJpc3Mi...SflKxw...
Auth0
Auth0 issues JWTs with RS256 by default and publishes JWKS at /{tenant}/.well-known/jwks.json. Their tokens include standard OIDC claims plus custom claims via 'Rules' and 'Actions' pipelines. Auth0 rotates signing keys automatically and keeps previous keys active for 10 days. They recommend 15-minute access token lifetimes with 7-day rotating refresh tokens.
Cloudflare Access
Cloudflare Access uses JWTs to protect applications behind Cloudflare's edge network. Each request to a protected app receives a JWT signed by Cloudflare's private key. The application validates the JWT against Cloudflare's JWKS endpoint, checking that iss, aud, and exp are correct. The JWT carries the authenticated user's email and identity provider, enabling zero-trust access without VPN.
Stripe
Stripe uses opaque access tokens (not JWTs) for API authentication but uses JWTs internally for service-to-service authentication. Internal JWTs include service identity, request ID, and authorization scope. Stripe's choice of opaque tokens for external APIs reflects a preference for server-side revocation (introspection) over self-contained validation.
| Aspect | Description |
|---|---|
| Self-Contained (JWT) vs. Opaque Tokens | JWTs can be validated locally without a network call to the IdP (fast, resilient). Opaque tokens require an introspection call (slower, IdP dependency) but can be revoked instantly. JWTs require short lifetimes + deny-lists for pseudo-revocation. |
| RS256 vs. ES256 vs. HS256 | RS256 (RSA) has broad library support but produces large signatures (256 bytes). ES256 (ECDSA) produces compact signatures (64 bytes) and is faster to verify, but library support is slightly less universal. HS256 (HMAC) is fastest but requires shared secrets -- dangerous in distributed systems. |
| Claim Richness vs. Token Size | Including roles, permissions, and user metadata in the JWT eliminates lookup calls but bloats the token. A 4KB JWT sent with every request adds significant bandwidth. Balance: include identity (sub, email, tenant_id) in the JWT; look up permissions on the server. |
| Key Rotation Frequency vs. Complexity | Frequent key rotation (every 30 days) limits the impact of key compromise but requires careful JWKS management and graceful transition periods. Infrequent rotation (yearly) is simpler but extends the window if a key is leaked. |
How a JWT 'alg: none' Bug Led to Authentication Bypass
Scenario
In 2015, security researcher Tim McLean discovered that many JWT libraries accepted tokens with 'alg: none' in the header, meaning no signature was required. An attacker could craft a JWT with any claims (e.g., sub: admin), set alg to 'none', strip the signature, and the library would accept it as valid because 'none' was a valid algorithm per the spec.
Solution
The fix was straightforward but required action across the ecosystem: JWT libraries were updated to reject 'alg: none' by default, and to require the verifier to specify which algorithms are accepted (allowlist) rather than trusting the token's header. The JOSE (JSON Object Signing and Encryption) community published best practices: always specify expected algorithms, never trust the token's alg claim, and validate the complete token (header + payload + signature) as a unit.
Outcome
The 'alg: none' vulnerability became a canonical example of why cryptographic protocol implementations must be defensive. All major JWT libraries (jose, jsonwebtoken, PyJWT, java-jwt) now require explicit algorithm specification. The incident also motivated the adoption of JWKS-based key distribution, where the verifier knows which algorithms and keys are valid for a given issuer, eliminating trust in the token's self-declared algorithm.
See JWT & Token Design in action
Explore system design templates that use jwt & token design and run traffic simulations to see how these concepts perform under real load.
Browse Templates1Why should multi-service systems use RS256 instead of HS256 for JWT signing?
2What is the purpose of the 'kid' (Key ID) field in a JWT header?