How to Validate a JWT Correctly (2026 Update)
Most JWT bugs aren't subtle cryptography failures. They're a library called with the wrong options, a claim nobody checked, or an alg header the server trusted when it shouldn't have. A JWT that decodes tells you nothing; a JWT that's validated tells you it was issued by who you think, for who you think, and hasn't expired. The gap between those two is where account-takeover bugs live.
This is the validation order that closes that gap, the two classic traps that bypass it entirely, and what the 2026 update to the JWT Best Current Practices adds. Examples use NamoID's stack — RS256 with keys published via JWKS — but the rules are the same whatever issuer you verify.
What a JWT actually is
A JWT is three base64url segments joined by dots: header.payload.signature. The header names the algorithm and key; the payload holds the claims (sub, iss, aud, exp, …); the signature covers the first two. Two things developers forget constantly:
- A JWT is signed, not encrypted. The payload is base64, not ciphertext — anyone can read it. Never put a secret in a JWT.
- The token is attacker-controlled input until you've verified the signature. Everything in it, including the
algheader, is whatever the sender typed. You don't get to trust any of it until the signature checks out — and you must decide the algorithm and key from your config, not from the token.
That second point is the source of the two famous bypasses.
The two traps that bypass validation
alg: none. The JWS spec defines an "unsecured" algorithm, none, with an empty signature. If your library accepts it, an attacker sends {"alg":"none"}, strips the signature, and forges any payload they like. Every claim becomes attacker-controlled. The fix: never accept none, and always pass an explicit allowlist of algorithms.
RS256 → HS256 key confusion. Your issuer signs with RS256 (an RSA private key); you verify with the RSA public key, which is, by design, public. The attack: the attacker changes the header to HS256 (HMAC, a symmetric algorithm) and signs the token using your public key as the HMAC secret. If your code calls "verify with this key" and lets the token pick the algorithm, the library runs HMAC-SHA256 with the public key — which the attacker also has — and the forged token verifies. The fix is the same: pin the expected algorithm yourself. Never let the token's header choose how it's verified.
Both traps have one root cause — trusting the alg header — and one fix: the verifier decides the algorithm.
The correct validation order
Validate in this sequence. Order matters: cheap structural checks and key resolution come before claim checks, and the signature is verified before any claim is trusted.
- Parse the header without trusting the body. Read
kid(key id) and confirmalgis one you expect. Rejectnonehere. - Resolve the key from JWKS by
kid. Fetch the issuer's JWKS document, find the key whosekidmatches, cache it. If no key matches, refresh the JWKS once (keys rotate) and only then fail. - Verify the signature with a pinned algorithm. Tell the library exactly which algorithm(s) are allowed —
["RS256"], not "whatever the token says." - Validate the registered claims:
iss— must equal your expected issuer, checked against an allowlist.aud— must contain your service. A token minted for another audience must be rejected (this is what stops a token from one service being replayed at another).exp/nbf/iat— enforce expiry and not-before, with a small clock-skew leeway (NamoID allows ±30 seconds, no more — enough for real drift, too little to matter).
- Check token type if relevant. Confirm it's the kind of token you expect (an access token, not an ID token) via
typor atoken_use-style claim, so the two aren't interchangeable.
Only after step 4 may you act on anything inside the payload.
In PyJWT, the safe call looks like this — note the pinned algorithm and the required claims:
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient("https://issuer.example/.well-known/jwks.json")
signing_key = jwks.get_signing_key_from_jwt(token) # resolves by kid
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"], # pin it — never read alg from the token
issuer="https://issuer.example",
audience="https://api.example", # reject tokens for other audiences
leeway=30, # clock-skew tolerance, in seconds
options={"require": ["exp", "iss", "aud"]},
)The dangerous version is jwt.decode(token, key, options={"verify_signature": False}) or omitting algorithms. If you see either in a code review, stop.
Why asymmetric signing makes this safer
NamoID signs tokens with RS256 and publishes only the public key at its JWKS endpoint; the private signing key never leaves the issuer. That's what makes verification both safe and stateless: any service can verify a token by fetching the public JWKS — no shared secret to distribute, no call back to the issuer per request, and a database is only consulted for revocation. It also means key rotation is a non-event for verifiers: rotate the signing key, publish the new public key under a new kid, and verifiers pick it up by kid automatically. (Symmetric HS256 has none of these properties — and it's the algorithm the key-confusion attack tries to trick you into.)
What the 2026 JWT BCP adds
The original JWT Best Current Practices (RFC 8725) codified "always pin the algorithm" and "validate every claim." Its successor, draft-ietf-oauth-rfc8725bis — in IETF Last Call as of mid-2026 — adds defenses against a newer set of tricks worth knowing:
- Algorithm case confusion — treating
none,None,NONEas distinct; match algorithm names exactly and case-sensitively. - Encryption/signature confusion — don't let a token meant for one operation be processed by the other.
- Compression /
p2cdenial of service — a JWE with a huge PBES2 iteration count or a compression bomb can burn CPU; bound these before processing.
None of these change the core order above — they harden the edges. If your validation already pins the algorithm, resolves keys from JWKS, and checks every registered claim, you're most of the way there; the BCP delta is about rejecting malformed or adversarial tokens before they cost you CPU.
FAQ
Can I just decode a JWT to read the user id? You can read it, but never trust it without verifying the signature first. The payload is attacker-controlled until then.
Do I need to check aud? Yes. Without it, a token issued for one service can be replayed at another. Audience validation is what binds a token to its intended recipient — it's also central to securing AI agents over MCP.
How much clock skew should I allow? A small fixed leeway — on the order of 30 seconds. Enough for real drift between servers, small enough that an expired token isn't usable for long.
RS256 or HS256? Prefer RS256 (or another asymmetric algorithm) for anything multi-service: verifiers need only the public key, and you sidestep the key-confusion attack entirely. Reserve HS256 for a single service that both signs and verifies with a secret it never shares.
Validate like the token is hostile
Because it is, until the signature checks out. Pin the algorithm, resolve the key from JWKS by kid, verify, then check iss, aud, and expiry with a tight leeway — in that order. Get that right and alg:none and RS256→HS256 stop being threats; skip a step and a forged token walks in looking legitimate.
NamoID issues RS256 tokens with keys published via JWKS from a single issuer, so your services verify statelessly with a public key and pick up rotations automatically. If you're building the verifying side, the order above is the whole job.