JSON Web Tokens (JWT)
The Stateless Microservices Problem
You’re building a distributed system with 10 microservices. A user logs in and gets a token. They make a request to Service A, which calls Service B, which calls Service C.
If each service calls the authentication server to validate the token, you’ve created a massive bottleneck and a critical dependency. The auth server becomes the single point of failure for your entire system. If it goes down for 5 minutes, your entire platform is down.
JWTs solve this elegantly: package the user’s identity and claims into a cryptographically signed token. Any service can verify the token independently without calling a central server. Services don’t need to trust each other; they only need to trust the signature.
But JWTs come with serious gotchas. This section will show you the pitfalls that have caused real breaches.
JWT Structure: Three Parts
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three parts separated by periods (.):
Header: Base64URL-encoded JSON
{
"alg": "HS256",
"typ": "JWT"
}
Payload: Base64URL-encoded JSON (the claims)
{
"sub": "1234567890",
"name": "Alice",
"iat": 1516239022,
"exp": 1516242622
}
Signature: Cryptographic proof that header and payload weren’t tampered with
Critical: Encoding ≠ Encryption
This is the biggest misconception about JWTs:
Anyone can Base64URL decode the payload and read it:
$ echo "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIn0=" | base64 -d
{"sub":"1234567890","name":"Alice"}
JWTs are signed, not encrypted. The signature proves the data wasn’t tampered with. The data itself is visible to anyone.
If you need to hide the payload from users (e.g., the token contains sensitive claims), use JWE (JSON Web Encryption) instead. JWE encrypts the payload so it’s not readable without the decryption key.
Pro tip: Never put sensitive data (passwords, API keys, social security numbers) in a JWT. Consider it public data — the only thing secret is the signing key.
Signing: HMAC vs Asymmetric
The signature is the security mechanism. How it’s created determines who can verify it.
HMAC (HS256, HS512)
Token creation:
signature = HMAC-SHA256(
"header.payload",
shared_secret_key
)
Token verification:
expected_sig = HMAC-SHA256("header.payload", shared_secret_key)
if signature == expected_sig: token is valid
Symmetric: Same key is used to sign and verify. The auth server has the key, and any other system that needs to verify tokens must also have the key.
Pros: Simple; fast Cons: Key must be shared with all systems that verify tokens (increases risk of key leakage); if one system is compromised, the key is compromised
Use case: Single-service or tightly integrated systems. Internal APIs where all services trust each other.
RSA / ECDSA (RS256, ES256)
Token creation (by auth server):
signature = Sign(
"header.payload",
private_key
)
Token verification (by any service):
isValid = Verify(
"header.payload",
signature,
public_key
)
Asymmetric: Private key signs (only the auth server), public key verifies (anyone can verify).
Pros: Public key can be distributed widely without compromising security; auth server is the only one who can create tokens; any service can verify without a shared secret Cons: Slower than HMAC (asymmetric crypto is computationally expensive); more complex key management
Use case: Distributed systems where multiple services need to verify tokens independently. The standard for OAuth 2.0 and OIDC.
The Validation Checklist
When you receive a JWT, you must validate:
1. Signature is valid
- For HS256: verify using shared secret
- For RS256: download public key from JWKS endpoint, verify signature
2. Token hasn't expired (exp claim)
- Current time < exp claim
- If expired, reject regardless of signature
3. Token hasn't been used before nbf (not-before) claim
- Current time > nbf claim
- If not yet valid, reject
4. Issuer (iss claim) matches expected issuer
- iss claim should equal your authorization server's identifier
- Prevents accepting tokens from a different service
5. Audience (aud claim) matches your service
- aud claim should include your service identifier
- Prevents a token issued for Service A from being used on Service B
6. Algorithm is expected
- Check that alg claim matches what you expect (RS256, not HS256, etc.)
- This prevents algorithm confusion attacks (see below)
Critical: Always validate the algorithm. Never say “if alg is HS256, use the public key as HMAC secret” — that’s the algorithm confusion vulnerability.
JWKS: JSON Web Key Set
In asymmetric signing (RS256), the public key is distributed via a JWKS endpoint:
GET https://auth.example.com/.well-known/jwks.json
Response:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key-1",
"n": "0vx7agoebGcQSuuPiLJXZ...",
"e": "AQAB",
"alg": "RS256"
},
{
"kty": "RSA",
"use": "sig",
"kid": "key-2",
"n": "xjlCRBqkQrIJ...",
"e": "AQAB",
"alg": "RS256"
}
]
}
Key rotation: The auth server regularly rotates keys (every month or year). Old keys are kept in the JWKS endpoint for a while so clients can still verify tokens signed with them. The kid (key ID) in the JWT header tells you which key to use.
Implementation pattern:
# Fetch JWKS once, cache it, refresh every hour
class JWTValidator:
def __init__(self):
self.jwks = self.fetch_jwks()
self.last_refresh = time.time()
def fetch_jwks(self):
response = requests.get("https://auth.example.com/.well-known/jwks.json")
return response.json()
def get_public_key(self, kid):
# Refresh JWKS if stale
if time.time() - self.last_refresh > 3600:
self.jwks = self.fetch_jwks()
for key in self.jwks["keys"]:
if key["kid"] == kid:
return key
raise ValueError(f"Key not found: {kid}")
def verify(self, token):
header = jwt.get_unverified_header(token)
kid = header.get("kid")
key = self.get_public_key(kid)
try:
payload = jwt.decode(
token,
key,
algorithms=["RS256"], # Explicitly allow only RS256
audience="my-service",
issuer="https://auth.example.com"
)
return payload
except jwt.ExpiredSignatureError:
raise ValueError("Token expired")
except jwt.InvalidSignatureError:
raise ValueError("Invalid signature")
JWT Pitfalls: Real Vulnerabilities
Pitfall 1: No Revocation Mechanism
A JWT is valid until it expires. You can’t invalidate it early.
Scenario: An employee is fired. You delete their account. But their JWT is still valid for 1 hour. They can still call your APIs.
Mitigations:
Option 1: Short expiry + refresh tokens
- Access token: 15 minutes (short enough that damage is limited)
- Refresh token: 7 days (stored securely in HttpOnly cookie)
- User refreshes access token frequently
- If you fire someone, their refresh token becomes invalid immediately
Option 2: Token blacklist/revocation list
- When revoking access, add the token to a blacklist
- On each token verification, check the blacklist
- Drawback: requires a database lookup (slows down auth), defeats the “stateless” benefit of JWTs
Option 3: Hybrid
- Access tokens are short-lived (15 min)
- Blacklist only refresh tokens
- When a refresh attempt hits the blacklist, you know to deny it
- New access tokens can be issued up to the expiry, but once it’s expired, refresh is needed
Best practice: Option 3. Short access tokens + refresh token rotation with blacklist.
Pitfall 2: Token Bloat
Every JWT you include in an Authorization header increases the size of every request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwicm9sZXMiOlsiYWRtaW4iXSwiZGVwYXJ0bWVudCI6ImVuZ2luZWVyaW5nIiwic3RhcnRfZGF0ZSI6IjIwMjAtMDEtMDEiLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwiaWF0IjoxNTE2MjM5MDIyfQ.signature...
Every HTTP request includes this. If your JWT is 2 KB and you have 100 requests/second, that’s 200 MB/second of auth data. Multiply by millions of requests per day.
Solution: Include only essential claims in the JWT. For additional data, call a separate endpoint.
// Good: minimal JWT
{
"sub": "user-123",
"iss": "https://auth.example.com",
"iat": 1516239022,
"exp": 1516242622
}
// Bad: bloated JWT
{
"sub": "user-123",
"name": "Alice",
"email": "[email protected]",
"roles": ["admin", "user"],
"department": "engineering",
"phone": "555-1234",
"address": {...},
"preferences": {...},
...
}
If you need user metadata, fetch it from a user service or cache it client-side.
Pitfall 3: Algorithm Confusion Attack
This is devastating. An attacker tricks a system into accepting a different algorithm.
Scenario:
Auth server creates token with RS256 (asymmetric):
Header: {"alg": "RS256"}
Token is signed with private key
Service validates token and gets the public key:
public_key = fetch_public_key()
But if the service code is:
if header.alg == "HS256":
verify(token, public_key) # WRONG: using public key as HMAC secret
An attacker can:
1. Create a token with HS256 algorithm
2. Sign it using the public key as the secret (attacker has the public key)
3. The service validates it as HS256 and accepts it
Result: Attacker can forge tokens without the private key!
Protection: Always validate the algorithm explicitly.
# WRONG: accepts any algorithm
payload = jwt.decode(token, key)
# CORRECT: explicitly allow only RS256
payload = jwt.decode(token, key, algorithms=["RS256"])
Never use:
# EXTREMELY WRONG: accepts "none" algorithm
payload = jwt.decode(token, options={"verify_signature": False})
Pitfall 4: Storing Sensitive Data in Payload
JWTs are Base64-encoded, not encrypted. Anyone can decode them.
Bad: Including a password or SSN
{
"sub": "user-123",
"password_hash": "bcrypt(...)",
"ssn": "123-45-6789"
}
Good: Include only IDs and derive sensitive data from a database
{
"sub": "user-123",
"email": "[email protected]"
// Query database for sensitive fields
}
Pitfall 5: Accepting “none” Algorithm
Some JWT libraries allow tokens signed with algorithm “none” (no signature) for testing purposes. This should never be enabled in production.
Bad token:
{
"alg": "none"
}
{
"sub": "user-123",
"admin": true
}
Attacker can forge any token because there's no signature to verify!
Protection: Always validate that alg is a known, expected algorithm. Never accept “none”.
Refresh Token Rotation
Best practice: issue a new refresh token with each access token, and invalidate the old one.
Initial login:
Client sends credentials
Server issues:
- access_token (15 min expiry)
- refresh_token_v1 (30 day expiry)
Access token expires:
Client sends: refresh_token_v1
Server verifies and issues:
- access_token_new (15 min expiry)
- refresh_token_v2 (30 day expiry)
Server blacklists: refresh_token_v1
If attacker steals refresh_token_v1:
Attacker sends: refresh_token_v1
Server checks blacklist: found, deny
OR
If attacker uses before legitimate user:
Attacker gets: access_token, refresh_token_v2
Server blacklists: refresh_token_v1
Legitimate user sends: refresh_token_v1
Server sees blacklisted token, denies
System detects token reuse and revokes all tokens for user
This way, token theft is detected the moment the legitimate user tries to refresh.
JWT Best Practices Checklist
☐ Use asymmetric signing (RS256, ES256) in distributed systems
☐ Validate algorithm explicitly (don't accept "none" or unexpected algorithms)
☐ Keep JWTs small (minimal claims, derive other data from database)
☐ Always validate exp, nbf, iss, aud claims
☐ Fetch and cache public keys from JWKS endpoint
☐ Implement short-lived access tokens (15-60 minutes)
☐ Use refresh tokens for longer-term authorization
☐ Never store sensitive data in JWT payload
☐ Use HttpOnly, Secure, SameSite cookies to store refresh tokens
☐ Implement refresh token rotation (new token per refresh)
☐ Monitor for token reuse and revoke on suspicious patterns
☐ Set appropriate cache control headers on JWKS endpoint
☐ Implement key rotation at your authorization server
JWT vs Opaque Tokens
Should you use JWTs or opaque tokens (random strings)?
| Aspect | JWT | Opaque Token |
|---|---|---|
| Stateless | Yes | No (requires server lookup) |
| Revocation | Hard (token valid until expiry) | Easy (lookup in database) |
| Token size | Large (contains claims) | Small |
| Verification | Local (no server call) | Remote (call authorization server) |
| Performance | Fast | Slower (network round-trip) |
| Security | If mis-implemented, dangerous | Simpler to implement securely |
| Debugging | Easy (decode and read claims) | Opaque (must look up in database) |
Recommendation: JWTs for access tokens (stateless, fast) + opaque tokens for refresh tokens (revocable, simple).
Or: Use opaque tokens everywhere if you have a robust token introspection service and the latency is acceptable.
A Real-World JWT War Story
A fintech startup used JWTs for API authentication. They included a claim account_id in the token. A developer noticed they could manipulate the JWT in the browser console and change account_id to another user’s ID.
The tokens were signed with HMAC-SHA256 using a weak secret: “secret”. An attacker could:
- Change the account_id claim
- Recalculate the signature with the weak secret
- Use the forged token to access other users’ accounts
What went wrong:
- Weak signing key
- No validation of the account_id against the authenticated user’s actual account
- No rate limiting on API calls (attacker could enumerate all account IDs)
Fix:
- Strong, randomly generated key
- Backend validates that the account_id in the token matches the user’s actual account
- Rate limiting and anomaly detection
- Regular key rotation
- Audit logging of account access
Key Takeaways
-
JWTs are signed, not encrypted. Anyone can read the payload. Don’t put secrets in there.
-
Use asymmetric signing in distributed systems. RS256 or ES256 so services can verify tokens without sharing secrets.
-
Validate the algorithm explicitly. Never accept “none” or unexpected algorithms. Always use
algorithms=["RS256"]in your code. -
Keep tokens short-lived and access tokens small. Access tokens expire after 15-60 minutes. Include only essential claims (sub, iss, aud, exp, iat).
-
Implement refresh token rotation. Issue a new refresh token with each access token. Blacklist the old one to detect token theft.
-
Validate all required claims. exp, nbf, iss, aud. Don’t trust the client’s JWT blindly.
-
JWTs are not a replacement for proper authorization. A valid JWT proves identity, not permissions. Always check permissions in your business logic.
-
Consider the trade-off. JWTs are stateless but hard to revoke. Opaque tokens are revocable but require a server call. Hybrid approach is often best.
Practice Scenarios
Scenario 1: Algorithm Confusion
You receive a JWT with alg: "HS256". Your code does:
key = fetch_public_key() # Gets the RSA public key
payload = jwt.decode(token, key) # Doesn't specify algorithms parameter
Can an attacker forge a valid token? (Hint: What if they sign with the public key as an HMAC secret?)
Scenario 2: Token Theft An attacker steals a user’s JWT from localStorage (via XSS). The JWT has a 1-hour expiry. What damage can the attacker do? How would you mitigate?
Scenario 3: Claim Validation
A user’s JWT includes {"sub": "123", "admin": true, "exp": 1234567890}. Your service checks if payload.admin: grant_admin_access() without validating other claims. What’s the attack vector?
(Hint: What if the JWT is from a different authorization server? What if it’s expired? What if the user_id doesn’t match the authenticated user?)
Connection to Next Section
JWTs are foundational for modern authentication, but they’re only part of the security picture. In the next section, we’ll explore encryption — protecting data at rest and in transit, key management, and cryptographic best practices for building secure systems.