Vetora logo
🎫Security

JWT & Token Design

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.

Overview

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.

Key Points
  • 1Use asymmetric signing (RS256 or ES256) for multi-service systems. The authorization server holds the private key; all other services verify with the public key via JWKS. This prevents signing key exposure.
  • 2Keep JWT payloads small (<1KB). Include identity claims (sub, iss, aud, exp, tenant_id) but not permissions or large data structures. Every byte adds overhead to every API request.
  • 3Set short expiry times (5-15 minutes for access tokens). Short lifetimes limit the window of exploitation if a token is stolen. Use refresh tokens for session continuity.
  • 4Publish signing keys via JWKS (/.well-known/jwks.json). Include the kid (key ID) in the JWT header so verifiers can match tokens to keys. Keep previous keys in JWKS during rotation to allow graceful transition.
  • 5Always validate: signature (against JWKS), iss (matches expected issuer), aud (matches this service), exp (not expired), and nbf (not before). Skipping any validation enables token forgery or misuse.
  • 6JWTs are signed, not encrypted. Anyone can read the payload. Never include secrets, passwords, or sensitive PII. Use JWE only if payload confidentiality is required (rare for auth tokens).
Simple Example

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

Real-World Examples

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.

Trade-Offs
AspectDescription
Self-Contained (JWT) vs. Opaque TokensJWTs 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. HS256RS256 (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 SizeIncluding 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. ComplexityFrequent 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.
Case Study

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.

Common Mistakes
  • Trusting the JWT's 'alg' header without validation. An attacker can change alg from RS256 to HS256 and sign with the public key (which is publicly available). The server, expecting HS256, verifies with the public key and accepts the forged token. Always specify the expected algorithm in the verifier; never trust the token's claim.
  • Not validating the 'aud' (audience) claim. Without aud validation, a token issued for service-a can be replayed against service-b. Each service must check that aud contains its own identifier. This prevents token replay across services.
  • Using long expiry times for access tokens. A 24-hour JWT that is stolen gives the attacker 24 hours of access. Use 5-15 minute access tokens with refresh tokens for session continuity. The short window limits blast radius.
  • Storing sensitive data in JWT payloads. JWTs are Base64URL-encoded, not encrypted. Anyone with the token can read all claims. Never include passwords, SSNs, internal database IDs, or business-sensitive data. Use JWE if payload encryption is required.
Related Concepts

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 Templates

Compare JWT validation latency vs session lookup

Metrics to watch
token_validation_mspayload_size_bytescache_hit_ratiothroughput_rps
Run Simulation
Test Your Understanding

1Why 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?

Deeper Reading