Series · 3 partsWhen Access Checks FailPart 2 · You are here
- 1The Pipeline Has No Secret. It Still Has a Cloud Identity.
- 2The Signature Is Valid. The Token Still Belongs Somewhere Else.You are here
- 3The Request Stayed Server-Side. The Credential Did Not.
JWT verification in 60 seconds
The sentence “the JWT signature is valid” sounds like a security conclusion. It is only a cryptographic observation.
It says that some key accepted the bytes under some algorithm. It does not yet say the key belongs to the expected issuer, the issuer meant the token for this API, the token is the right kind of credential, or the claims authorize the requested operation. Those are separate bindings, implemented by separate checks, and a token can pass the first while belonging somewhere else entirely.
Most JWT findings are not broken cryptography. They are correct cryptography attached to the wrong trust decision.
Verification is a chain of bindings
The useful mental model is not “verify the token.” It is five questions in order:
- Algorithm → key. Is this exact algorithm allowed for this token class, and is this key allowed to be used with it?
- Key → issuer. Did the key come from the configured issuer, through a trusted key set, rather than from a location named by untrusted token input?
- Issuer → subject. Is this issuer trusted to speak for this subject in this application?
- Token → audience. Was the token minted for this API or relying party?
- Token type → validator. Is an access token being handled by access-token rules, and an ID token by ID-token rules, with mutually exclusive acceptance criteria?
RFC 8725 treats these as distinct requirements for a reason. A library can verify a signature perfectly while the application around it accepts the wrong algorithm, fetches the wrong key, skips audience validation, or uses one validation profile for every JWT-shaped value.
The header is input, not policy
The JWT header arrives from the caller. Fields such as alg, kid, jku, and x5u can help
a verifier locate the intended algorithm or key, but they cannot be allowed to define the
verifier’s trust policy by themselves.
alg must be checked against a server-side allowlist for the token class. The application
should not ask the token which algorithm it wants and then accommodate the answer. Each key
must be bound to the algorithm it is expected to serve.
kid is a selector inside a configured key set, not a filesystem path, database fragment,
or arbitrary lookup expression. jku and x5u are URLs; following a URL supplied by the
token without an issuer-bound allowlist turns signature verification into an outbound
request primitive. RFC 8725 explicitly calls out injection risk in kid handling and SSRF
risk when applications blindly follow key URLs.
The safe direction is always from configuration to token:
configured token profile
-> exact issuer
-> issuer-owned JWKS endpoint
-> allowed algorithms and key uses
-> required audience and token type
-> claim validation
The dangerous direction starts at the token header and lets it choose the validator, the key location, or the algorithm.
Audience is the substitution control
An issuer often mints tokens for several applications. All of them can be signed by the same active key, and all of them can therefore pass the same signature check. Audience is what prevents a token issued for client B or API B from being substituted at API A.
This is why a test using a self-signed or tampered token answers the wrong question. Every reasonable service rejects invalid signatures. The productive negative control is a valid token from the same issuer that was minted for a neighbouring audience. If API A accepts it, the problem is not cryptography. It is that the recipient never proved the token was meant for it.
The same applies to issuer separation. If staging and production use different issuers or tenants, a valid staging token is the cleanest control for whether production binds keys and subjects to the configured production issuer.
Token types need mutually exclusive rules
An ID token tells a client about an authentication event. An access token authorizes a
client to call a resource server. They may both be JWTs. They may come from the same issuer,
use the same signing key, and contain overlapping claims. That visual similarity is exactly
why one generic verifyJwt() helper becomes dangerous.
A robust system has separate validation profiles:
const accessTokenProfile = {
issuer: "https://id.example.com/",
audience: "https://api.example.com",
algorithms: ["RS256"],
typ: "at+jwt",
requiredClaims: ["iss", "sub", "aud", "exp", "scope"],
};
const idTokenProfile = {
issuer: "https://id.example.com/",
audience: "web-client-id",
algorithms: ["RS256"],
requiredClaims: ["iss", "sub", "aud", "exp", "nonce"],
};
The exact fields vary by protocol and issuer. The design property does not: a token should be accepted by one intended profile, not by whichever generic validator happens to parse it.
Build a substitution matrix, not a payload list
For each receiving endpoint, obtain tokens through approved test clients and map the nearest valid-but-wrong credential:
- right issuer, wrong audience;
- right issuer and audience, wrong token type;
- staging issuer, production audience look-alike;
- expired token and not-yet-valid token;
- valid key ID from the right issuer versus unknown key ID;
- key URL that is not the configured issuer JWKS location.
The positive control is the intended access token succeeding. Each negative control changes one binding while preserving the others. That makes the result explainable: when the wrong audience succeeds, you have evidence of missing audience validation rather than a generic “JWT bypass.”
Do not use real customer tokens in test evidence. Mint short-lived test identities, redact encoded tokens, and record decoded non-secret claims plus response outcomes. Bearer tokens do not become safe because they are pasted into a report.
Evidence matrix
| Signal | What it proves | Negative control | Defender verification |
|---|---|---|---|
| Intended token succeeds under one explicit validation profile | The positive authentication path and expected claim contract are known | Remove one required claim in a lab issuer or use an expired token and require rejection | Record configured issuer, audience, algorithms, token type, clock skew, and required claims |
| Same-issuer token for another audience is rejected | Audience binding prevents cross-client or cross-API substitution | Present a valid wrong-audience token; signature should pass while acceptance fails | Add automated substitution tests for every neighbouring client and API |
| Staging or alternate-issuer token is rejected before claims are trusted | Keys are bound to the configured issuer | Use a valid token from the alternate issuer with similar claims | Pin issuer metadata and JWKS location through configuration, not token headers |
Unknown kid fails without secondary lookup or outbound fetch | Key selection stays inside the issuer-owned key set | Supply an unknown key ID and observe a bounded rejection | Monitor JWKS refreshes and outbound destinations from authentication components |
| ID token is rejected at the resource API | Token classes use mutually exclusive validation rules | Present a valid ID token from the same issuer and client family | Separate ID-token and access-token middleware and test typ, audience, and required claims |
The pattern I keep seeing
The identity team configures the issuer. A platform library verifies signatures. Individual APIs decide which claims mean access. Each layer performs its own part correctly, and the gap appears because nobody owns the full acceptance decision.
Then the report says “JWT validation issue,” which sounds like a library upgrade. The actual finding is narrower and more useful: “API A accepts access tokens issued for API B because the shared middleware verifies the signature but does not enforce the receiving audience.”
What to hand the defenders
One validation profile per token class and recipient. The profile should begin with server-side configuration and end with required claims. No security choice should originate only from the received header.
A substitution test suite. Valid-but-wrong tokens are the negative controls that keep issuer, audience, and type boundaries from regressing.
Observable key selection. Log issuer, key ID, validation profile, and rejection reason without logging the token. Alert when a verifier attempts an unexpected JWKS destination or repeatedly sees unknown key IDs.
The signature answers whether the bytes were signed by a key. The application still has to answer whether that key and those bytes belong here.
How current is this note?
The latest source-review, content-update, or publication date is shown.
The author completed a technical review. This does not, by itself, claim lab reproduction.
