Validating JWT Claims Against a Contract
Symptom: a request arrives with a valid-looking bearer token, the signature check passes, and three call frames later something throws Cannot read properties of undefined (reading 'organizationId'). The token was genuine — it simply did not carry the claim your code assumed. This guide is part of Securing API Contracts with Auth Schemes and treats the token as what it is: an untrusted payload that deserves the same schema discipline as a request body.
Root Cause: A Token Is a Payload
Teams validate request bodies rigorously and then read the token like a trusted object. But a token is data from outside your system, and “signed” only means it came from an issuer you trust — not that it carries the claims this API needs, in the types this API expects.
The audience check deserves special attention. In an estate where one identity provider issues tokens for a dozen services, omitting aud verification means any service’s token opens any other service — a privilege-escalation path that no amount of signature checking closes.
Step 1: Verify the Signature and Registered Claims
Use jose 5, which handles key-set fetching, caching and rotation.
// auth/verify.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
// The key set is cached and refreshed automatically when an unknown 'kid' appears,
// which is what makes issuer key rotation a non-event.
const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"), {
cooldownDuration: 30_000, // do not hammer the issuer on a burst of bad tokens
cacheMaxAge: 600_000,
});
export async function verifyToken(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://auth.example.com/", // exact match, trailing slash included
audience: "https://api.example.com/v2", // THIS API, not the whole estate
clockTolerance: 30, // seconds — explicit, not a library default
algorithms: ["RS256"], // pin it; never trust the token's own header
});
return payload;
}
Why pinning the algorithm matters: a verifier that reads the algorithm from the token’s header can be steered into accepting none, or into treating a public key as an HMAC secret. Naming the algorithms you accept removes an entire class of attack in one line.
Step 2: Validate the Claims With a Schema
The registered claims are now trustworthy. The custom ones are still unverified data, and Zod treats them the way it treats a request body.
// auth/claims.ts
import { z } from "zod";
export const claimsSchema = z.object({
sub: z.string().min(1),
// Space-delimited by the OAuth2 spec — split it here, once, not at every call site.
scope: z.string().transform((s) => s.split(" ").filter(Boolean)),
org_id: z.string().uuid(),
roles: z.array(z.enum(["viewer", "operator", "admin"])).default([]),
// Optional: present only for tokens minted through the partner flow.
partner_id: z.string().optional(),
}).strip(); // ignore unknown claims rather than rejecting on them
export type Principal = z.infer<typeof claimsSchema>;
.strip() rather than .strict() is deliberate here, and it is the opposite of the advice for request bodies. An issuer may add claims at any time — a new auth_time, a new tenant hint — and a token verifier that rejects unknown claims will break on the identity provider’s next release. Ignore what you do not need; validate what you do.
// auth/middleware.ts
import type { RequestHandler } from "express";
import { verifyToken } from "./verify";
import { claimsSchema } from "./claims";
export const authenticate: RequestHandler = async (req, res, next) => {
const header = req.get("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return unauthorized(res, "no bearer token presented");
let payload;
try {
payload = await verifyToken(token);
} catch (err) {
// jose throws distinct error codes; map them without echoing the token.
const reason = (err as { code?: string }).code === "ERR_JWT_EXPIRED"
? "the token has expired"
: "the token could not be verified";
return unauthorized(res, reason);
}
const parsed = claimsSchema.safeParse(payload);
if (!parsed.success) {
const fields = parsed.error.issues.map((i) => i.path.join(".")).join(", ");
return unauthorized(res, `the token is missing or malformed claims: ${fields}`);
}
res.locals.principal = parsed.data; // typed from here on
next();
};
function unauthorized(res: Parameters<RequestHandler>[1], detail: string) {
res.set("WWW-Authenticate", 'Bearer realm="api", error="invalid_token"');
res.status(401).type("application/problem+json").json({
type: "https://docs.example.com/errors/unauthenticated",
title: "Authentication required",
status: 401,
code: "UNAUTHENTICATED",
detail,
});
}
Step 3: Never Touch the Raw Token Downstream
Once the middleware has parsed the claims, handlers should read res.locals.principal and nothing else. A handler that re-decodes the token is a handler that will one day decode it without verifying it.
router.get("/orders", (req, res) => {
const { org_id, scope } = res.locals.principal as Principal;
// org_id is a validated UUID; scope is a string array. No optional chaining needed.
return res.json(await orders.listForOrg(org_id, { canSeeArchived: scope.includes("orders:admin") }));
});
Verification
# A token for a different audience must be rejected even though it is validly signed.
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/v2/orders \
-H "authorization: Bearer $TOKEN_FOR_OTHER_SERVICE"
# 401
# An expired token says so, specifically.
curl -s https://api.example.com/v2/orders -H "authorization: Bearer $EXPIRED" | jq -r .detail
# the token has expired
# A token missing a required custom claim names the claim.
curl -s https://api.example.com/v2/orders -H "authorization: Bearer $NO_ORG" | jq -r .detail
# the token is missing or malformed claims: org_id
# No response ever echoes token content.
curl -s https://api.example.com/v2/orders -H "authorization: Bearer $EXPIRED" | grep -c "$EXPIRED"
# 0
Edge Cases and Caveats
audas a string or an array. The claim may be either, and hand-rolled checks that assume a string will reject valid tokens. Use a library that handles both.- Key rotation with no
kid. Some issuers omit the key identifier, forcing verifiers to try every key in the set. It works, but it makes a rotation window noticeably slower and hides which key signed a token. Ask the issuer to include it. - Very large claim sets. A token carrying every group a user belongs to can exceed a proxy’s header limit, producing an opaque 431 or a truncated request. Keep the token small and look up group membership server-side when the list is unbounded.
- Symmetric signing across services. An HMAC-signed token means every verifier holds a key that can also mint tokens. Use asymmetric signing so verifiers hold only a public key.
- Discriminated token shapes. When user tokens and machine tokens carry genuinely different claim sets, model the two with a discriminated union rather than a soup of optional fields — the same reasoning as in fixing Zod discriminated union mismatches.
Where the Claim Contract Should Live
The claim schema sits between two teams: the identity provider decides what goes into a token, and the API decides what it needs. When those are the same team, keeping the schema next to the verification code is enough. When they are not, the schema is a contract and deserves the same treatment as any other.
The workable arrangement is that the API publishes what it requires rather than the issuer publishing what it emits. An issuer’s token contains claims for many audiences; your API needs four of them. Declaring those four — with their types and formats — gives the identity team a concrete list to preserve, and gives you a schema that fails loudly if a claim disappears.
Two things follow from that. A claim your API requires is a breaking change for the issuer to remove, so it belongs in whatever change process the identity provider runs. And a claim the issuer adds is not a change for you at all, which is why the schema strips unknown claims rather than rejecting them.
Where several services share an issuer, publishing each service’s claim requirements makes the union visible: if six services require org_id and one requires tenant_id for the same concept, that is worth resolving before it becomes eight services.
Rotation and Revocation
Two operational realities are worth designing for before they happen.
Key rotation should be a non-event. A verifier that fetches the key set with a short cache and refreshes automatically on an unknown key identifier handles a rotation without a deploy. Pinning a single key, or caching the set for a day, turns a routine rotation into an outage — and it is the identity team who will be blamed for it.
Revocation is harder, because a signed token is valid until it expires whether or not you want it to be. Short lifetimes are the primary control: a fifteen-minute access token bounds the damage of a leaked credential without any revocation infrastructure. Where genuine revocation is required — a compromised account, a terminated employee — it needs a check against a revocation list on every request, which trades the main advantage of stateless tokens for a guarantee some systems genuinely need. Decide which you are buying, and write it down.
Frequently Asked Questions
Is verifying the signature enough?
No. A correctly signed token from the wrong issuer, for the wrong audience, or past its expiry is still invalid for your API. Signature verification proves the token was minted by a key you trust; the registered claims prove it was minted for you, and that it is still current.
Why validate custom claims with a schema instead of reading them directly?
Because a claim that is absent, null, or a string where you expected an array turns into undefined deep inside business logic. A schema turns every one of those into a 401 at the edge with a precise message, which is both safer and easier to debug.
Should a malformed token return 401 or 400?
- The request failed to authenticate, whatever the reason — missing header, bad signature, expired, or a claim that does not match the contract. Reserve 400 for a well-authenticated request whose body is malformed.
How much clock skew should I tolerate?
Thirty to sixty seconds. Enough to absorb ordinary drift between the issuer and your servers, small enough that an expired token is not usable for meaningfully longer than it should be. Set it explicitly rather than relying on a library default you have not read.
Should the token contract live in the OpenAPI document?
Declare the security scheme and the bearer format there, and keep the claim schema next to the verification code where it is executable. Duplicating the claim shape into the specification adds a second copy to maintain without giving consumers anything they can act on.