---
title: "The Signature Is Valid. The Token Still Belongs Somewhere Else."
description: "JWT verification fails when applications treat one cryptographic success as a complete access decision. Algorithm, key, issuer, audience, and token type are separate bindings — skip one and a perfectly valid token crosses the wrong boundary."
date: 2025-10-23
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/posts/jwt-verification-boundary/
---

## 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:

1. **Algorithm → key.** Is this exact algorithm allowed for this token class, and is this
   key allowed to be used with it?
2. **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?
3. **Issuer → subject.** Is this issuer trusted to speak for this subject in this application?
4. **Token → audience.** Was the token minted for this API or relying party?
5. **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.

<figure class="diagram">
<svg viewBox="0 0 700 286" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="JWT validation as five gates. The token header selects an allowed algorithm, the key is bound to a configured issuer, issuer and subject are validated, audience is matched to the receiving API, and token type is checked by a dedicated validation profile. A valid signature is shown as only the second gate, not the final access decision.">
<defs>
<marker id="jwt-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent" /></marker>
</defs>
<text x="0" y="18" class="dg-muted">A VALID SIGNATURE IS ONE GATE IN A LARGER ACCEPTANCE PIPELINE</text>
<rect x="0" y="34" width="120" height="68" rx="8" class="dg-box" />
<text x="14" y="56" class="dg-label">algorithm</text>
<text x="14" y="76" class="dg-mono">allowlist</text>
<text x="14" y="92" class="dg-muted">not header-led</text>
<line x1="122" y1="68" x2="146" y2="68" class="dg-line-accent" marker-end="url(#jwt-a)" />
<rect x="150" y="34" width="120" height="68" rx="8" class="dg-box" />
<text x="164" y="56" class="dg-label">key</text>
<text x="164" y="76" class="dg-accent">SIGNATURE</text>
<text x="164" y="92" class="dg-muted">issuer-owned</text>
<line x1="272" y1="68" x2="296" y2="68" class="dg-line-accent" marker-end="url(#jwt-a)" />
<rect x="300" y="34" width="120" height="68" rx="8" class="dg-box" />
<text x="314" y="56" class="dg-label">issuer</text>
<text x="314" y="76" class="dg-mono">iss + sub</text>
<text x="314" y="92" class="dg-muted">identity scope</text>
<line x1="422" y1="68" x2="446" y2="68" class="dg-line-accent" marker-end="url(#jwt-a)" />
<rect x="450" y="34" width="120" height="68" rx="8" class="dg-box-accent" />
<text x="464" y="56" class="dg-label">audience</text>
<text x="464" y="76" class="dg-accent">THIS API</text>
<text x="464" y="92" class="dg-muted">recipient</text>
<line x1="572" y1="68" x2="596" y2="68" class="dg-line-accent" marker-end="url(#jwt-a)" />
<rect x="600" y="34" width="100" height="68" rx="8" class="dg-box-accent" />
<text x="614" y="56" class="dg-label">type</text>
<text x="614" y="76" class="dg-mono">access</text>
<text x="614" y="92" class="dg-muted">profile</text>
<line x1="0" y1="130" x2="700" y2="130" class="dg-line dg-dash" />
<text x="0" y="156" class="dg-muted">TWO TOKENS — BOTH CRYPTOGRAPHICALLY VALID, ONLY ONE ACCEPTABLE HERE</text>
<rect x="0" y="172" width="336" height="76" rx="8" class="dg-box-accent" />
<text x="14" y="194" class="dg-accent">issuer A · aud API-A · access+jwt</text>
<text x="14" y="214" class="dg-mono dg-compact">validated by API-A access-token profile</text>
<text x="14" y="232" class="dg-muted">signature and context agree</text>
<rect x="364" y="172" width="336" height="76" rx="8" class="dg-box" />
<text x="378" y="194" class="dg-crit">issuer A · aud CLIENT-B · id token</text>
<text x="378" y="214" class="dg-mono dg-compact">signature valid, recipient and type wrong</text>
<text x="378" y="232" class="dg-muted">must be rejected before authorisation</text>
</svg>
<figcaption>Cryptographic validity is necessary and deliberately insufficient. The receiving service must bind algorithm, key, issuer, audience, and token type to one configured validation profile before any claim becomes trustworthy.</figcaption>
</figure>

## 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:

```text
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:

```ts
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.