JWT pitfalls
Article.
The signature verified. What could still be wrong?
That is the whole question, and the answer is “most things”. alg: none and
RS256/HS256 confusion are the famous attacks. They are mostly closed in
maintained libraries, though “closed” depends on the version you pin: PyJWT
has required algorithms= since 2.0 (December 2020) and still fixed another
algorithm-confusion gap in 2.13.0 (May 2026).
They are not what breaks real systems in 2026. The failures that bite now involve perfectly valid, correctly signed tokens accepted by a service that had no business accepting them.
The mechanism: a signature answers one question
Verifying a JWT’s signature establishes exactly one fact: this was issued by a holder of the signing key. Nothing else.
signature valid -> "someone I trust issued this"
it does NOT say -> it was issued FOR me (aud)
-> it came from the issuer I meant (iss)
-> it is still valid now (exp, nbf)
-> it is the KIND of token I want (typ)
-> the claims are still true (staleness)Every remaining pitfall is one of those five lines. A JWT is a bearer assertion: it says “the issuer asserted these claims at issue time”, and turning that into an authorization decision requires you to check who it was for, who made it, when, what kind it is, and whether the assertion has since gone stale.
Libraries check some of these by default and skip others. PyJWT checks exp
only when the token carries one, refuses a token that has an aud unless you
name your audience, and checks iss only if you pass issuer=. That is why
“we verify the signature” is an incomplete answer.
What complete validation looks like
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient(
"https://auth.example.com/.well-known/jwks.json",
lifespan=300, # JWK-set cache; refetched early on an unknown kid
) # no cache_keys=True: it pins keys with no expiry
def decode(token: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
signing_key,
algorithms=["RS256"], # pinned, never from the header
audience="https://api.example.com", # it was issued FOR me
issuer="https://auth.example.com", # by the party I meant
leeway=30, # tolerate modest clock skew
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
typ = jwt.get_unverified_header(token).get("typ", "").lower()
if typ not in ("at+jwt", "application/at+jwt"): # RFC 9068 access token
raise jwt.InvalidTokenError("not an access token")
return claimsThree details do most of the work:
algorithms=is a pin, not a hint. Passing it is what makes both classic attacks impossible; omitting it is what made them possible.requireturns missing into invalid. Without it, a token with noexpis a token that never expires, because there is nothing to check.audandissuerare the confused-deputy defence. PyJWT raisesInvalidAudienceErrorfor a token carryingaudwhen you pass noaudience=, so the deputy usually ships when someone silences that error (options={"verify_aud": False}) or lists every service’s audience.issis the quiet one: omitissuer=and it is simply not checked.
When your IdP does not set typ
The typ check only works if your IdP issues RFC 9068 access tokens. Many
still send typ: JWT or no typ at all (Entra ID, Keycloak and Auth0’s
default profile among them; Cognito marks tokens with a token_use claim
instead). There, tell an access token from an ID token by aud (your API, not
the client id), by token_use, and by the presence of scope.
The famous ones, briefly
Worth being able to explain, mostly historical.
alg: none. The token declares it is unsigned and a naive library agrees.
Closed by default everywhere current, and reintroduced by
options={"verify_signature": False} left in a code path.
RS256 to HS256 confusion. The token declares HS256 and the attacker
signs it with the public key, which the server then uses as an HMAC secret.
Only possible when the verification call takes the algorithm from the token.
What PyJWT says to each attempt:
jwt.decode(forged_hs256, public_pem, algorithms=["RS256"])
# InvalidAlgorithmError: The specified alg value is not allowed
jwt.decode(forged_hs256, public_pem, algorithms=["RS256", "HS256"])
# InvalidKeyError: ... an asymmetric key ... should not be used as an HMAC secret
jwt.decode(token_with_aud, key, algorithms=["RS256"]) # no audience=
# InvalidAudienceError: Invalid audience
jwt.decode(token_without_exp, key, algorithms=["RS256"], options={"require": ["exp"]})
# MissingRequiredClaimError: Token is missing the "exp" claimBoth attacks reduce to the same rule: the token never gets to choose how it is verified. The second line is PyJWT’s own guard catching a bad allow-list; do not rely on it, and never mix symmetric and asymmetric algorithms in one list. Other libraries were slower: python-jose fixed an algorithm-confusion CVE with ECDSA keys (CVE-2024-33663) only in 3.4.0 (2025), and FastAPI’s docs moved to PyJWT.
The failures that are not about the algorithm
The token that was valid, for someone else
The signature checks out, exp is in the future, and the token was issued for
a different service.
auth.example.com issues tokens for:
aud=billing-api -> can charge cards
aud=analytics-api -> read-only reports
billing-api does not check aud
-> a token minted for analytics is accepted by billingAnyone who can obtain a low-value token from the same issuer now holds a
high-value one. This is a confused deputy, and it is the single most
common real JWT flaw, because every service in the estate trusts the same
signer and aud is the only thing distinguishing them.
The same applies to iss in reverse: without it, adding a second identity
provider silently makes every service accept tokens from both.
An ID token used as an access token
OIDC issues two tokens with different purposes. The ID token describes the user to the client application; the access token authorizes a call to an API. They are both JWTs from the same issuer, and an API that only checks the signature will accept either.
Accepting an ID token at an API is a privilege problem: its aud is the
client id, it carries no scopes, and it is routinely handed to front-end code
that was never treated as a credential store. RFC 8725, the JWT Best Current
Practice, calls this cross-JWT confusion and asks for explicit typing:
at+jwt for access tokens, logout+jwt for logout tokens, dpop+jwt for
DPoP proofs. Check typ where your IdP sets it, and aud everywhere.
kid, jku and other attacker-controlled headers
The header is not signed content you can trust — it is input.
kidselects which key to use, and a naive implementation interpolates it into a file path or a SQL query.kid: "../../dev/null"with an empty-string HMAC secret is the classic; SQL injection throughkidis the other.jku/x5utell the verifier where to fetch the key from. Honouring them means the attacker supplies both the token and the key that validates it.
kid = jwt.get_unverified_header(token)["kid"]
if kid not in KNOWN_KEYS: # lookup in a fixed set, never a path
raise jwt.InvalidTokenError("unknown kid")Treat kid as an opaque lookup key against a set you control, and never
resolve jku or x5u at all.
sub is only unique within an issuer
The multi-tenant bug that appears the day you add a second identity provider.
sub is unique per issuer, not globally, so two IdPs can legitimately emit
the same sub for two different people.
user = db.get(User, claims["sub"]) # breaks with a second IdP
user = db.get_by(issuer=claims["iss"], subject=claims["sub"]) # correctThe key is the pair. Getting this wrong is a cross-tenant account takeover
that looks like a data bug. Mint sub as a string, too: since PyJWT 2.10
(November 2024) a numeric sub or jti fails validation, so an upgrade
starts rejecting tokens the service minted itself.
Claims are a snapshot, not the truth
role: admin was true when the token was minted. The user was demoted four
minutes ago and the token is valid for another eleven.
Every claim is stale by design, which is the actual cost of statelessness. The options are a short access-token lifetime, re-reading the sensitive attribute from the database at the point of use, or a lookup that reintroduces state. Carrying an identifier and looking up permissions is usually the right call — it also avoids the token growing without bound. See Stateful and stateless.
Where it breaks in production
JWKS fetching and key rotation
PyJWKClient caches the key set for lifespan seconds and refetches it once
when a token names a kid it has not seen, so a rotation to a new kid heals
itself. The failures are elsewhere:
cache_keys=Truepins keys forever. It wraps key lookup in an LRU cache with no time limit, separate from the key-set lifespan, so a key the IdP removed after a compromise stays trusted until a restart. Leave it off.- Unknown
kids trigger fetches. Randomkidvalues become outbound requests from your service to your IdP: an amplification primitive. PyJWT throttles them since 2.14.0 (September 2026) and keeps the cached set when a fetch fails since 2.13.0; older versions need a wrapper that does both. - The fetch blocks. It uses blocking
urllibwith a 30-second default timeout, so in an async service a cache miss stalls the event loop. Refresh the key set in the background, or call it in a thread.
Rotate on a timeline: publish the new key at least one verifier cache lifespan
before signing with it, keep the old key for the longest token lifetime plus
that lifespan, then remove it. An IdP that reuses a kid with new key
material defeats the kid-miss refetch entirely.
Clock skew
exp and nbf are absolute times compared against the verifier’s clock. A
node drifting thirty seconds rejects freshly issued tokens with “not yet
valid”, and it does so for only the fraction of traffic that reaches it — so
the symptom is intermittent auth failures nobody can reproduce.
A small leeway absorbs it. NTP fixes the cause.
The test helper that shipped
def decode(token, verify=True):
return jwt.decode(token, KEY, options={"verify_signature": verify})Written to make a test suite convenient, called with verify=False from one
endpoint eighteen months later. Grep the codebase for verify_signature,
verify_exp and verify_aud — every occurrence should be zero or in a test.
Seeing it happen
Count validation failures by reason: expired, aud, iss, signature,
unknown kid, typ. An unknown-kid spike right after an IdP rotation means
a stale cache; a steady background of them means someone is probing. Log
iss, kid and jti, never the token itself.
localStorage
A JWT in localStorage is readable by any JavaScript on the page, so an XSS
is a full credential theft rather than a session hijack.
| Storage | XSS | CSRF |
|---|---|---|
| localStorage | full token theft | none |
| HttpOnly cookie | not readable | needs defence |
| In-memory + cookie | one tab, short-lived | needs defence |
The last row is the sensible default for an SPA that must hold tokens: the
access token lives in a JS variable and dies on refresh, the refresh token in
an HttpOnly, SameSite cookie. XSS can still use the token from the
compromised tab, but cannot exfiltrate a long-lived credential. A BFF keeps
tokens out of the browser entirely (OAuth 2.0 and OpenID Connect).
The payload that is not secret
A JWT is signed, not encrypted. The payload is base64url and anyone holding the token can read it, which makes “we put the user’s date of birth in the claims” a data disclosure rather than a design choice.
JWE exists for confidentiality, and by the time you need it an opaque token with a server-side store is usually simpler and better.
Signing keys
- HS256 — one shared secret, so anyone who can verify can also mint. Fine inside a single application, wrong the moment a second service needs to verify.
- RS256 / ES256 / EdDSA — the private key signs and only the public key is distributed, so a compromised resource server cannot issue tokens. Use asymmetric for anything beyond a monolith.
The choice also sets the size every request carries: the signature alone is 43 base64url characters under HS256, 86 under ES256 or EdDSA, and 342 under RS256 with a 2048-bit key. Claims add the rest, and headers have ceilings (JWT Claims).
Publish a JWKS at a stable URL, key entries by kid, and support multiple
keys during overlap.
The trade not taken
Every mitigation above is work you would not do with an opaque token: a random string, a lookup at the resource server, and the authorization state lives in one place where you can change it.
That buys immediate revocation, no stale claims, no header attacks, no key distribution and nothing readable in the token. It costs a lookup per request and an availability dependency on the introspection endpoint.
The right question is not “JWT or session” but “how long can a revocation take to bite?” If the answer is “immediately” — admin sessions, financial operations, anything where a compromised account must be stopped now — you need a lookup, and a JWT with a blocklist is an opaque token with extra parsing.
If a five-minute window is acceptable, a short-lived JWT with a revocable refresh token removes a lookup from a very hot path, and that is a real win at volume.
Pick the token type from the revocation requirement, not from which one sounds more modern.
Related
Interview angle 6
- “How do you validate a JWT?” — verify the signature with a pinned
algorithm list, then check
iss,aud,expandnbf, and require those claims to be present so that missing means invalid rather than unchecked. The signature only establishes who issued it.- Follow-up: “The signature verified. What could still be wrong?” — it could have been issued for a different service, be the wrong kind of token, be expired against a skewed clock, or carry claims that were true at issue and are not now. Signature verification is the first check, not the last.
- “What is the
alg: noneattack, and algorithm confusion?” — the token declares it is unsigned, or declares HMAC so the attacker can sign with the public key. Both exist only when the verifier takes the algorithm from the token. Pinalgorithms=and both are impossible.- Follow-up: “So are those still a real risk?” — in maintained libraries,
rarely, but it depends on the pin: python-jose fixed one only in 2025 and
PyJWT closed another gap in 2.13.0 (2026). They also come back through
verify_signature=Falseleft in a helper. Missingaudand stale claims are what actually happens.
- Follow-up: “So are those still a real risk?” — in maintained libraries,
rarely, but it depends on the pin: python-jose fixed one only in 2025 and
PyJWT closed another gap in 2.13.0 (2026). They also come back through
- “PyJWT rejects your IdP’s tokens with ‘Invalid audience’, and a teammate
adds
verify_aud: False. What did they ship?” — a confused deputy: every token the issuer mints for any service is now accepted. The fix is passing your API’s audience, which is exactly what the error was asking for.- Follow-up: “Your IdP’s access tokens say
typ: JWT. How do you tell them from ID tokens?” — byaud(your API, not the client id), Cognito’stoken_use, and the presence ofscope. Never on signature alone.
- Follow-up: “Your IdP’s access tokens say
- “How do you rotate the signing key with zero failed requests?” — publish
the new key at least one verifier cache lifespan before signing with it,
keep the old one for the longest token lifetime plus that lifespan, then
remove it. Watch for a reused
kidand for verifiers that pin keys (cache_keys=True). - “Someone sends a token with an unfamiliar
kid. What happens?” — it should be a lookup miss in a fixed key set. Ifkidreaches a file path or a query it is an injection point, and if it triggers a JWKS fetch per request it is an amplification vector against your own identity provider. - “So when would you not use a JWT?” — when revocation has to take effect immediately, or when the claims are sensitive, or when there is one service and no key distribution problem to solve. Choose the token type from how long a revocation may take to bite.