Skip to main content

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.

Four checks, four different guarantees Signature proves the minting key; issuer proves who minted it; audience proves it was minted for this API; the claim schema proves it carries what this API needs. Skipping any one leaves a distinct hole. 1. signature minted by a key we trust 2. iss + exp from our issuer, still current 3. aud minted for THIS API, not another 4. claim schema carries what this API needs skip it: forged tokens skip it: expired tokens skip it: a token for another service works here skip it: undefined, deep in business logic the third check is the one most often missing in internal APIs

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,
  });
}
Every rejection names its own reason Four distinct failure points — no header, bad signature, expired, malformed claims — each produce a 401 whose detail says which check failed, without echoing any token content. no Authorization bad signature expired org_id missing one 401 helper one problem document 401 problem+json "no bearer token presented" "could not be verified" "the token has expired" "malformed claims: org_id" The detail names the failed check — never the claim values, and never the token itself.

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.

One decode, at the edge The middleware decodes and validates once and attaches a typed principal; handlers, services and the audit log all read that object, so nothing downstream can decode a token without verifying it. middleware verify + parse, once typed principal sub, org_id, scope[] handlers domain services audit log no layer below the middleware ever sees the raw token
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

  • aud as 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?

  1. 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.