Security / security / authentication / tokens
JWT Validation Is Not Signature Verification
That a JWT with a valid signature is a valid token. The signature establishes only that some key you hold signed these bytes. It says nothing about which issuer minted the token, which service it was minted for, whether it is still in date, or whether the algorithm and key were chosen by you or by the token — and a library called with defaults checks none of those.
A verified signature answers one question: were these exact bytes signed by a key I hold? It does not answer who issued the token, who it was minted for, what kind of token it is, or whether it is still in date. Every one of those is a separate check, and a library called with defaults performs none of them.
The panel below is a resource server — a billing API — deciding whether to accept a bearer token. On the left is the token as presented. On the right is the set of checks the verifier has been configured to run. Turn the checks off one at a time and read the log: it prints every step of the validation pipeline, the verdict, and the reason.
Start with the jwt.verify(token, key) preset and leave the token legitimate. It is accepted, and the log is almost entirely grey — steps that did not run. Now change one thing about the token. The verdict does not move.
check ran · nothing to check · the gap this token walked through
The instructive combination is alg: none with the naive preset. There is no signature at all — the third segment of the token is empty — and the token is still accepted, because a verifier that reads its instructions out of the header will faithfully carry out an instruction to check nothing. Turn on the algorithm list and the same token is rejected before a key is ever loaded. That ordering is the whole idea: the algorithm is a property of your configuration, never of the token.
Why the header cannot be trusted, even though it is signed
The usual objection is that the JOSE header is inside the signed input, so
tampering with it should break the signature. That is true and it does not
help. To check a signature at all, the verifier must first decide which
algorithm to run and which key to run it with — and the only place that
information appears in the token is the header it has not yet verified.
alg is consumed before verification, so at the moment of use it
is unauthenticated input. RFC 7515 §4.1.1 makes alg mandatory
precisely so the verifier knows what to do; it never says the verifier
should obey it.
RFC 8725 — the JWT Best Current Practices document — states the rule as an implementation requirement in §3.1: a library MUST let the caller specify a supported set of algorithms and MUST NOT use any others, and each key MUST be used with exactly one algorithm. Two distinct failures follow from ignoring it, both listed in §2.1:
-
alg: none. The unsecured JWS is a real, registered variant with an empty signature segment. A verifier without an allowlist treats "no signature" as "signature checked". -
RS256 rewritten as HS256. The verifier holds one key for the
issuer: an RSA public key. Handed a token whose header asks for HMAC, a
library that takes a single opaque key parameter and picks the algorithm
from the header will use those public key bytes as the HMAC secret. The
key is published — the issuer serves it from its
jwks_uri— so the MAC verifies. RFC 8725 cites this as CVE-2015-9235.
Note what this means for the defence: rejecting none is not
enough. The second case uses a perfectly respectable algorithm. Only an
allowlist bound to the key type closes both. Set the token to
HS256 in the panel with the algorithm check off and read the
signature line — it says verified, and that is the honest report of
what the cryptography did.
Most maintained libraries now refuse to verify without an explicit
algorithm list. The failure survives in the layers people write around
them: gateway plugins, bespoke middleware, a helper that takes
alg from the decoded header "so it works with both our
issuers", and test doubles that leak into production paths.
kid is a hint, not an instruction
RFC 7515 §4.1.4 defines kid as "a hint indicating which key was
used" — it selects among keys you already trust. The failure is treating it
as a lookup instruction, and the sharpest form of that is the sibling
headers jku (§4.1.2) and x5u (§4.1.5), which carry
a URL. A verifier that resolves a key by dereferencing a URL the token
supplies has let the token nominate its own judge. RFC 8725 §3.10 asks
implementations to treat all of these as untrusted received claims: don't
follow jku or x5u blindly, because it is
server-side request forgery with extra steps, and sanitise kid
before it reaches a database or directory lookup.
Select the jku option and toggle the key-source check. With it on, the log rejects at key resolution and never reaches the signature. With it off, the signature verifies perfectly — against a key the presenter chose. This is the case where "the signature is valid" is most obviously worthless.
The correct binding is the one RFC 8725 §3.8 requires: the keys used must
belong to the issuer. In OpenID Connect that binding is concrete — the
iss value is an HTTPS URL whose metadata document names a
jwks_uri, and that URL is the only place keys come from. So
iss checking is not merely a claim check; it is what makes key
resolution meaningful. Checking iss while accepting keys from
anywhere, or resolving keys correctly while ignoring iss, each
leaves half the binding undone.
Choose kid 2027-01-x to see the other outcome, the one your on-call
rotation actually meets: no matching key, rejected either way,
IDX10501 Signature validation failed. Unable to match 'kid' or
your stack's equivalent. That is a key-rotation liveness bug — the issuer
signed with a key your cache has not picked up — and it is worth learning to
tell apart from the security cases above, because the log line looks
similar and the fix is completely different. See
how key sets rotate.
aud and the substitution attack
RFC 8725 §2.7 names this directly. A token legitimately presented to one recipient is replayed by that recipient at a different one. Nothing is forged; the signature is genuine, the issuer is yours, the token is in date. The notifications service, holding a real token a user gave it, calls the billing API with it. If billing checks only the signature, the call succeeds.
That is why RFC 7519 §4.1.3 is unusually strong for an optional claim: each
principal processing a JWT MUST identify itself with a value in
aud, and if it does not find itself there when the claim is
present, it MUST reject the token. RFC 8725 §3.9 completes the rule for the
common case — if one issuer serves more than one relying party, the token
MUST carry aud and the recipient MUST reject it when the
audience is absent or not associated with it. In the panel, the
absent option is the one people find surprising: a missing
aud is not a pass, it is a token that names no recipient and
therefore names no one.
The spa-client-7f3 option is the neighbouring problem, cross-JWT
confusion (§2.8): an OIDC ID token is a JWT from the same issuer, signed
with the same key, whose audience is the browser client's ID. It is a
statement about who logged in, not an authorisation to call anything. An
API that verifies signatures and skips aud cannot tell the two
apart. RFC 8725 §3.11 and §3.12 add the defence-in-depth layer: give each
kind of token an explicit typ — at+jwt for OAuth
access tokens — and write validation rules for different kinds of token
that are mutually exclusive, so one profile's rules reject the other's
tokens outright.
iss deserves the same suspicion. Multi-tenant providers issue
tokens per tenant from the same infrastructure; some serve them from the
same key. Select /tenant-b and turn the issuer check off, and a
signature from your own provider carries someone else's tenant into your
API. This is also the check that people quietly weaken first, usually by
accepting a list of issuers during a migration and never trimming it back.
Where validation ends
Every check in the panel is a check on the token in front of you. None of them is a check on the world. Three limits follow, and they are the reason stateless tokens are a trade rather than a free win.
There is no revocation. A signed, unexpired, correctly audienced
token remains valid until exp, whatever has happened to the
session behind it. A user disabled at 10:00 keeps working until their
access token runs out. The only levers are short lifetimes, a denylist
consulted on every request, or token introspection — and the last two put
the network call back that JWTs were adopted to remove. See
the revocation problem.
Clock skew is a real parameter. RFC 7519 §4.1.4 permits "some small
leeway, usually no more than a few minutes" on exp, and §4.1.5
the same on nbf. Leeway of an hour to make a flaky test pass is
an hour of extra token lifetime for every token, and it never gets removed.
Validation is not confidentiality. The payload is base64url, not encryption. Anything in a JWT is readable by anything that handles it, including proxies, logs and browser storage.
Checking your own service
Read the call site, not the middleware's name. What you are looking for is a verification call whose arguments do not mention an algorithm list, an audience and an issuer:
-
Node,
jsonwebtoken:jwt.verify(t, key)with no options object. Thealgorithms,audienceandissueroptions each default to unchecked. -
Python, PyJWT:
jwt.decode(t, key, algorithms=[...])withoutaudience=andissuer=. Worse,options={"verify_signature": False}disables the claim checks too — a combination people reach for while debugging and then commit. -
Go,
golang-jwt: aKeyfuncthat returns a key without inspectingtoken.Method. That function is the algorithm allowlist; if it ignores the method, there isn't one. -
Java / Spring: a
JwtDecoderbuilt from a JWK set URL alone. Issuer and audience live in theOAuth2TokenValidatorchain, and the audience validator is the one you have to add yourself. - Gateways: whatever your ingress calls its JWT plugin, find the field that lists permitted algorithms and the field that lists accepted audiences. If either is blank, it is not filtering on them.
Then test it from outside, in staging: take a token your own service issues for a different audience and present it to this API. That single request is the whole of the substitution attack from the defender's side, and a correct service answers 401 with an audience error. If it answers 200, you have found it. This is also the test to put in CI, because audience checking is configuration and configuration regresses silently.
Your API rejects alg: none, pins the issuer, checks
exp, and resolves keys only from the issuer's
jwks_uri. It does not check aud. Your identity
provider also issues tokens to an internal reporting service. What is
exposed?
Next: the flow that hands out these tokens in the first place, and the two parameters everyone conflates — PKCE and the state parameter. Then the other validation pipeline engineers assume is one check, certificate chain validation, and the storage side of authentication, password hashing and work factors.