JWT Validation Pitfalls
Key Points
- A JWT is signed (or encrypted), not secret. Anyone can read its payload — base64-decode and look. Don't put secrets in claims.
- Validate everything: signature, issuer, audience, lifetime, key (
kid), algorithm. The library does this if you configure it correctly — but defaults can bite. - Famous attacks:
alg=none, algorithm confusion (HS256 forged using RS256 public key as HMAC secret),kidinjection, audience confusion, expired-but-cached, missingnbf/expvalidation. - Clock skew matters: services in different time zones / drifting clocks reject valid tokens. ASP.NET defaults to 5 min — tighten to 1–2 min if you can.
- You cannot revoke a JWT before its
exp. Plan for that: short lifetimes (5–15 min), refresh tokens, or a denylist. - Storage in browsers: HttpOnly cookies, not localStorage. XSS = total compromise.
Concepts (deep dive)
What's in a JWT?
header.payload.signature
header = base64url({ "alg": "RS256", "typ": "JWT", "kid": "abc" })
payload = base64url({ "sub": "u123", "iss": "https://idp", "aud": "api", "exp": 1714000000 })
signature = sign(header + "." + payload, key)
Header tells you the algorithm and key. Payload is the claims. Signature lets you verify.
Decode any JWT at jwt.io. This is normal; don't put PII or secrets in claims.
The famous attacks
1. alg=none
Some libraries historically trusted alg: none and accepted unsigned tokens. Always disable. ASP.NET's JwtBearer rejects none by default.
2. Algorithm confusion (HS256/RS256)
The IdP signs with RS256 (asymmetric: private key signs; public key verifies). The attacker grabs the public key (it's public!), then forges a token signed with HS256 using that public key as the HMAC secret. A naive validator that accepts whatever alg the header claims will verify it.
Mitigation: restrict to expected algorithms in TokenValidationParameters:
o.TokenValidationParameters = new TokenValidationParameters
{
ValidAlgorithms = new[] { "RS256" } // reject HS256 etc.
};
ASP.NET's defaults are reasonable but be explicit.
3. kid injection / SQL injection in kid
Some implementations look up keys by kid from a DB or filesystem. An attacker can put a path traversal or SQL payload in kid. Treat kid as untrusted input.
4. Audience confusion
Token issued for service A is replayed against service B. Mitigation: each service validates aud claim is its own audience.
5. Issuer spoofing / multiple issuers
If your validator accepts multiple issuers, attacker uses a token from a less-trusted IdP. Pin to expected issuers.
6. Expired-but-cached signing keys
JWKS rotates. If your validator caches stale keys, valid new tokens get rejected, OR worse, revoked keys still validate. Use Authority + automatic JWKS refresh.
7. Missing exp / nbf validation
A token without exp is forever. Always require lifetime validation:
o.TokenValidationParameters.RequireExpirationTime = true;
o.TokenValidationParameters.ValidateLifetime = true;
Correct ASP.NET configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = "https://login.microsoftonline.com/{tenant}/v2.0";
o.Audience = "api://my-api";
o.RequireHttpsMetadata = true; // enforce HTTPS for JWKS
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://login.microsoftonline.com/{tenant}/v2.0",
ValidateAudience = true,
ValidAudience = "api://my-api",
ValidateLifetime = true,
RequireExpirationTime = true,
ValidateIssuerSigningKey = true,
ValidAlgorithms = new[] { "RS256" },
ClockSkew = TimeSpan.FromMinutes(2), // tighter than 5min default
NameClaimType = "preferred_username",
RoleClaimType = "roles"
};
o.MapInboundClaims = false; // keep original claim names (don't auto-map "sub" → ClaimTypes.NameIdentifier)
});
MapInboundClaims = false
By default the JWT handler renames standard JWT claim types to .NET's ClaimTypes URIs (http://schemas.xmlsoap.org/...). This is confusing — your tokens say sub, your code reads ClaimTypes.NameIdentifier. Setting MapInboundClaims = false keeps the claim names verbatim:
var sub = User.FindFirstValue("sub");
var email = User.FindFirstValue("email");
var roles = User.FindAll("roles").Select(c => c.Value);
Recommended for new projects.
Clock skew
Server A's clock: 12:00:00
Server B's clock: 11:59:30 (30s behind)
Token nbf: 12:00:00, exp: 12:05:00
Server B at 11:59:50 sees a "future" token → rejects.
Default ClockSkew = 5 min. Tighten when your fleet is NTP-synced; loosen if not. Production: 1–2 min.
Token revocation
You can't un-issue a JWT. Strategies:
- Short access tokens (5–15 min) + refresh tokens. User logs out → revoke refresh.
- Reference tokens — an opaque token that's looked up server-side; revoked instantly.
- Denylist — store revoked-token JTIs (JWT IDs) in Redis until their
exp. - Key rotation — rotate signing key; all tokens invalidate.
Most apps live with the short-lifetime trade-off.
Browser storage
| Storage | XSS-safe? | Notes |
|---|---|---|
| HttpOnly cookie | Yes | Send via cookie, not header |
| localStorage | No | XSS = total compromise |
| sessionStorage | No | Same |
| In-memory (JS variable) | Mostly | Lost on refresh; mitigates persistence |
Best for SPAs: BFF pattern. SPA never sees the token; backend holds it, sets cookie session. SPA sends cookie automatically.
Token bloat
Putting roles/permissions/claims into JWT makes it grow. 10 KB tokens kill request perf. Keep token slim; load detailed permissions server-side via claims transformation.
Asymmetric vs symmetric signing
- HS256 (HMAC) — symmetric secret. Both signer and verifier need the secret. Fine for monolith.
- RS256/ES256 — asymmetric. Signer uses private key; verifiers use public key (JWKS). Required for federation across services.
For multi-service architectures, always RS256/ES256.
JWKS and key rotation
The IdP publishes keys at {authority}/.well-known/jwks.json. The token's kid selects which key. Rotation:
- IdP adds new key to JWKS (both old and new available).
- IdP signs new tokens with new key.
- Old tokens still verify with old key.
- Eventually old key is removed from JWKS.
ASP.NET refreshes JWKS automatically (default 24h, plus on signature failure).
Encrypted JWTs (JWE)
A JWE is encrypted, not just signed — payload isn't readable. Useful when claims contain sensitive data. Rare; usually you just don't put sensitive data in claims.
Code: correct vs wrong
❌ Wrong: trusting alg from header
// Pseudo-code (some libraries used to do this!):
var alg = headerJson["alg"];
var key = (alg == "HS256") ? hmacSecret : rsaPublicKey;
verify(token, key, alg); // attacker chooses alg
✅ Correct: pin algorithm
❌ Wrong: missing audience validation
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false // accepts tokens for any service
};
✅ Correct: pin audience
o.TokenValidationParameters.ValidateAudience = true;
o.TokenValidationParameters.ValidAudience = "api://my-api";
❌ Wrong: storing JWT in localStorage
localStorage.setItem("token", jwt);
fetch("/api", { headers: { Authorization: "Bearer " + localStorage.getItem("token") }});
✅ Correct: HttpOnly cookie via BFF
Response.Cookies.Append("auth", jwt, new CookieOptions
{
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict
});
Design patterns for this topic
Pattern 1 — "Strict TokenValidationParameters"
- Intent: explicit
ValidAlgorithms,ValidIssuer,ValidAudience, lifetime.
Pattern 2 — "Short access + refresh"
- Intent: mitigate revocation gap.
Pattern 3 — "BFF for browsers"
- Intent: SPA never sees the token.
Pattern 4 — "JTI denylist for must-revoke"
- Intent: Redis-backed revocation set keyed by
jti.
Pattern 5 — "Claims transformation for permissions"
- Intent: keep token slim; load full permissions server-side per request.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| JWT | Stateless; scales | Hard to revoke |
| Reference tokens | Instant revocation | Stateful; introspection cost |
| Short JWT + refresh | Best of both | More complexity |
| Encrypted (JWE) | Confidential payload | Slower; less tooling |
When to use / when to avoid
- Use JWT for service-to-service, mobile, native apps.
- Use cookies for browsers (BFF).
- Use reference tokens when revocation must be instant (banking, healthcare).
- Avoid HS256 in multi-service systems.
- Avoid
MapInboundClaims = truein new projects — keep claim names verbatim.
Interview Q&A
Q1. Why is the alg=none attack possible? Older libraries trusted the header's alg field. If they accepted none, an unsigned token verified. Always pin ValidAlgorithms.
Q2. What's algorithm confusion? Server expects RS256 but doesn't pin it. Attacker forges an HS256 token using the server's public RSA key as the HMAC secret. The naive validator accepts it.
Q3. Why is aud validation important? Without it, a token issued for one API can be replayed against another. aud ties tokens to a specific audience.
Q4. Why short lifetimes for access tokens? You can't revoke a JWT before exp. Short = small window for stolen tokens.
Q5. What's kid? Key ID in JWT header — tells the verifier which JWKS key to use. Treat as untrusted input.
Q6. JWT vs reference token? JWT: self-contained; verifier doesn't call IdP. Reference token: opaque; verifier calls IdP introspection. JWT scales better; reference revokes instantly.
Q7. Why not store JWT in localStorage? XSS reads localStorage. HttpOnly cookies are JS-inaccessible.
Q8. What's a refresh token? Long-lived token used to mint new short-lived access tokens. Should be store-able only server-side or in HttpOnly cookie.
Q9. How does JWKS rotation work? IdP publishes multiple keys; tokens reference one via kid. Rotation: add new key, start signing with it, retire old key after all tokens with old kid expire.
Q10. What's ClockSkew? Tolerance for clock differences. Default 5 min. Tighten in production.
Q11. Why MapInboundClaims = false? Default mapping renames JWT claim types to .NET ClaimTypes URIs, hiding the original names. False keeps them verbatim — clearer.
Q12. Can JWT contain PII safely? Only if encrypted (JWE). Signed-only JWTs are readable. Treat them as plaintext.
Gotchas / common mistakes
- ⚠️ Trusting
algfrom header — pin algorithms. - ⚠️ Missing
ValidateAudience— token reuse across services. - ⚠️ Default 5-min clock skew — too generous; tighten.
- ⚠️ Putting roles in JWT — bloat; stale on role change.
- ⚠️ JWT in localStorage — XSS = pwned.
- ⚠️ No HTTPS for JWKS endpoint (
RequireHttpsMetadata = false) — MITM = key swap. - ⚠️ Long-lived access tokens — revocation gap.