Backend / Security / 06_jwt_pitfalls.md

JWT pitfalls

Updated 6 interview angles 12 min read source
On this page9
  1. The mechanism: a signature answers one question
  2. What complete validation looks like
  3. The famous ones, briefly
  4. The failures that are not about the algorithm
  5. Where it breaks in production
  6. Signing keys
  7. The trade not taken
  8. Related
  9. Interview angle

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.

text
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

python
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 claims

Three 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.
  • require turns missing into invalid. Without it, a token with no exp is a token that never expires, because there is nothing to check.
  • aud and issuer are the confused-deputy defence. PyJWT raises InvalidAudienceError for a token carrying aud when you pass no audience=, so the deputy usually ships when someone silences that error (options={"verify_aud": False}) or lists every service’s audience. iss is the quiet one: omit issuer= 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:

python
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" claim

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

text
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 billing

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

  • kid selects 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 through kid is the other.
  • jku / x5u tell the verifier where to fetch the key from. Honouring them means the attacker supplies both the token and the key that validates it.
python
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.

python
user = db.get(User, claims["sub"])            # breaks with a second IdP

user = db.get_by(issuer=claims["iss"], subject=claims["sub"])   # correct

The 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=True pins 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. Random kid values 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 urllib with 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

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

Interview angle 6

  • “How do you validate a JWT?” — verify the signature with a pinned algorithm list, then check iss, aud, exp and nbf, 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: none attack, 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. Pin algorithms= 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=False left in a helper. Missing aud and stale claims are what actually happens.
  • “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?” — by aud (your API, not the client id), Cognito’s token_use, and the presence of scope. Never on signature alone.
  • “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 kid and 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. If kid reaches 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.