Design a JWT validator
Design the library every service uses to accept bearer tokens: pinned algorithms, kid lookup against a cached JWKS that survives key rotation, exp, nbf and iat with bounded skew, issuer and audience checks, typed claims and an error taxonomy callers can act on. No `alg: none`, no algorithm confusion.
01Understand the problem
- Pin algorithms in config so the token never chooses how it is verified
- Look up keys by kid in a cached JWKS that handles rotation without a fetch storm
- Check exp, nbf and iat with a bounded clock skew
- Return a typed result with an error code, never a bare boolean
What they probe: where the algorithm comes from, what happens with alg: none or HS256 signed with the public key, how an unknown kid is handled, how the cache behaves when the IdP rotates keys or is down, and the order of checks. Where candidates slip: trusting the header alg, fetching the JWKS URL from the token, parsing claims before verifying the signature, an unbounded refresh on every unknown kid.
validate(token)returns claims or a typed error. It never throws for a bad token.- Accept only algorithms pinned in config (asymmetric:
RS256,ES256,EdDSA). - Keys come from the configured issuer's JWKS, cached; an unknown kid triggers at most one refresh per interval.
expis required;nbfandiatare checked when present; skew is configurable and capped at 5 minutes.issmust equal the configured issuer;aud(string or array) must contain the configured audience.
Non-functional: p99 under 0.2 ms on a cache hit; no network call on the hot path; the IdP being down for an hour must not take every service down with it.
02Interfaces first
import type { KeyObject } from "node:crypto";
export type Alg = "RS256" | "ES256" | "EdDSA";
export interface Jwk { kid: string; alg: Alg; use?: "sig" | "enc"; key: KeyObject }
/** Fetches and parses the issuer's JWKS. Parse with createPublicKey({ key, format: "jwk" });
* drop keys without kid or alg and any key carrying private members (d). */
export interface KeySource { fetch(issuer: string): Promise<readonly Jwk[]> }
export interface KeyLookup { get(kid: string): Promise<Jwk> } // throws JwtError
export interface Clock { nowSec(): number }
export interface ValidatorConfig {
issuer: string;
audience: string;
algs: readonly Alg[]; // pinned: the header may only pick from this list
skewSec: number; // 0..300
maxAgeSec?: number; // if set, iat is required and must be recent
requiredClaims?: readonly string[];
}
export type JwtErrorCode =
| "malformed" | "unsupported_alg" | "unknown_kid" | "keys_unavailable" | "bad_signature"
| "expired" | "not_yet_valid" | "issued_in_future" | "too_old"
| "wrong_issuer" | "wrong_audience" | "missing_claim";
export class JwtError extends Error {
constructor(readonly code: JwtErrorCode, message: string) { super(message); this.name = "JwtError"; }
}
export interface Claims { iss: string; sub: string; aud: string | string[]; exp: number; nbf?: number; iat?: number; jti?: string; [k: string]: unknown }
export type Result = { ok: true; claims: Claims; kid: string } | { ok: false; error: JwtError };
export interface JwtValidator { validate(token: string): Promise<Result> }Callers act differently on expired (refresh the token), keys_unavailable (503, page someone) and bad_signature (401, count it as an attack signal). A boolean collapses all three into one useless 401 and hides IdP outages.
03The validator
- ACTOR
- EDGE
- SERVICE
- DATA
- Live flow
- Lookup / async
import { verify as cryptoVerify, type KeyObject } from "node:crypto";
const B64URL = /^[A-Za-z0-9_-]+$/;
const KEY_TYPE: Record<Alg, string> = { RS256: "rsa", ES256: "ec", EdDSA: "ed25519" };
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
const isNumericDate = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0;
function decodeJson(part: string): unknown {
if (!B64URL.test(part)) throw new JwtError("malformed", "segment is not base64url");
try { return JSON.parse(Buffer.from(part, "base64url").toString("utf8")); }
catch { throw new JwtError("malformed", "segment is not JSON"); }
}
function verifySignature(alg: Alg, key: KeyObject, data: Buffer, sig: Buffer): boolean {
if (key.asymmetricKeyType !== KEY_TYPE[alg]) return false; // an RSA key can never verify an EdDSA token
try {
switch (alg) {
case "RS256": return cryptoVerify("sha256", data, key, sig);
case "ES256": return sig.length === 64 && cryptoVerify("sha256", data, { key, dsaEncoding: "ieee-p1363" }, sig);
case "EdDSA": return cryptoVerify(null, data, key, sig);
}
} catch { return false; }
}
export class Validator implements JwtValidator {
constructor(private readonly cfg: ValidatorConfig, private readonly keys: KeyLookup, private readonly clock: Clock) {
if (!cfg.issuer || !cfg.audience) throw new Error("issuer and audience are required");
if (cfg.algs.length === 0) throw new Error("pin at least one alg");
if (!Number.isInteger(cfg.skewSec) || cfg.skewSec < 0 || cfg.skewSec > 300) throw new Error("skewSec must be an integer in [0, 300]");
if (cfg.maxAgeSec !== undefined && !(cfg.maxAgeSec > 0)) throw new Error("maxAgeSec must be positive");
}
async validate(token: string): Promise<Result> {
try { return { ok: true, ...(await this.check(token)) }; }
catch (e) {
if (e instanceof JwtError) return { ok: false, error: e };
throw e; // a bug, not a bad token: do not mask it as a 401
}
}
private async check(token: string): Promise<{ claims: Claims; kid: string }> {
if (token.length > 8192) throw new JwtError("malformed", "token too large");
const parts = token.split(".");
if (parts.length !== 3) throw new JwtError("malformed", "expected three segments");
const [h64, p64, s64] = parts;
const header = decodeJson(h64);
if (!isObject(header)) throw new JwtError("malformed", "header is not an object");
const alg = header["alg"];
if (typeof alg !== "string" || !this.cfg.algs.some((a) => a === alg)) throw new JwtError("unsupported_alg", "alg not allowed: " + String(alg));
if ("crit" in header) throw new JwtError("malformed", "crit extensions are not supported");
const kid = header["kid"];
if (typeof kid !== "string" || kid.length === 0 || kid.length > 128) throw new JwtError("malformed", "kid missing or invalid");
const jwk = await this.keys.get(kid);
if (jwk.alg !== alg) throw new JwtError("unsupported_alg", "key " + kid + " is not for " + alg);
if (!B64URL.test(s64)) throw new JwtError("malformed", "signature is not base64url");
const ok = verifySignature(jwk.alg, jwk.key, Buffer.from(h64 + "." + p64), Buffer.from(s64, "base64url"));
if (!ok) throw new JwtError("bad_signature", "signature does not verify");
return { claims: this.checkClaims(decodeJson(p64)), kid }; // claims are read only after the signature holds
}
private checkClaims(p: unknown): Claims {
if (!isObject(p)) throw new JwtError("malformed", "payload is not an object");
const now = this.clock.nowSec();
const skew = this.cfg.skewSec;
if (p["iss"] !== this.cfg.issuer) throw new JwtError("wrong_issuer", "unexpected issuer");
if (typeof p["sub"] !== "string" || p["sub"] === "") throw new JwtError("missing_claim", "sub");
const aud = p["aud"];
const auds = typeof aud === "string" ? [aud] : Array.isArray(aud) && aud.every((a) => typeof a === "string") ? aud : null;
if (!auds) throw new JwtError("missing_claim", "aud");
if (!auds.includes(this.cfg.audience)) throw new JwtError("wrong_audience", "token is for another audience");
const exp = p["exp"];
if (!isNumericDate(exp)) throw new JwtError("missing_claim", "exp");
if (now - skew >= exp) throw new JwtError("expired", "expired");
const nbf = p["nbf"];
if (nbf !== undefined) {
if (!isNumericDate(nbf)) throw new JwtError("malformed", "nbf is not a NumericDate");
if (now + skew < nbf) throw new JwtError("not_yet_valid", "not valid yet");
}
const iat = p["iat"];
if (iat !== undefined) {
if (!isNumericDate(iat)) throw new JwtError("malformed", "iat is not a NumericDate");
if (iat > now + skew) throw new JwtError("issued_in_future", "iat in the future");
if (this.cfg.maxAgeSec !== undefined && now - iat > this.cfg.maxAgeSec + skew) throw new JwtError("too_old", "token older than maxAgeSec");
} else if (this.cfg.maxAgeSec !== undefined) {
throw new JwtError("missing_claim", "iat");
}
for (const c of this.cfg.requiredClaims ?? []) if (!(c in p)) throw new JwtError("missing_claim", c);
return p as Claims;
}
}04JWKS cache and rotation
IdPs rotate by publishing the new key in the JWKS before signing with it, then removing the old key after the longest token lifetime. The cache has to pick up a new kid quickly, keep serving when the IdP blips, and never let forged kids turn into a fetch storm.
export class JwksCache implements KeyLookup {
private entry?: { keys: Map<string, Jwk>; fetchedAt: number };
private inflight?: Promise<void>;
private lastAttemptAt = -Infinity;
constructor(private readonly src: KeySource, private readonly issuer: string, private readonly clock: Clock,
private readonly ttlSec = 600, private readonly minRefreshSec = 30, private readonly maxStaleSec = 86_400) {
if (!(minRefreshSec > 0 && ttlSec >= minRefreshSec && maxStaleSec >= ttlSec)) throw new Error("need 0 < minRefresh <= ttl <= maxStale");
}
async get(kid: string): Promise<Jwk> {
const now = this.clock.nowSec();
const due = !this.entry || now - this.entry.fetchedAt >= this.ttlSec;
if (due && this.mayRefresh(now)) await this.refresh();
if (!this.entry || now - this.entry.fetchedAt >= this.maxStaleSec) throw new JwtError("keys_unavailable", "no fresh enough JWKS");
let k = this.entry.keys.get(kid);
if (!k && this.mayRefresh(now)) { // maybe rotated in; forged kids are rate limited here
await this.refresh();
k = this.entry.keys.get(kid);
}
if (!k) throw new JwtError("unknown_kid", "no key for kid " + kid);
return k;
}
/** Join a fetch already in flight (concurrent first requests must wait, not fail), or start one if the rate limit allows. */
private mayRefresh(now: number) { return this.inflight !== undefined || now - this.lastAttemptAt >= this.minRefreshSec; }
/** Single flight: concurrent callers share one fetch. Never rejects. */
private refresh(): Promise<void> {
this.inflight ??= this.load().finally(() => { this.inflight = undefined; });
return this.inflight;
}
private async load(): Promise<void> {
const now = this.clock.nowSec();
this.lastAttemptAt = now;
try {
const keys = await this.src.fetch(this.issuer);
this.entry = { keys: new Map(keys.filter((k) => k.use !== "enc").map((k) => [k.kid, k])), fetchedAt: now };
} catch {
// keep the previous entry; get() refuses it once it is older than maxStaleSec. Emit a metric here.
}
}
}| Situation | Behaviour |
|---|---|
| Cache hit | No I/O; map lookup |
| TTL expired | One refresh (single flight), then serve |
| Unknown kid | One refresh if the last attempt is older than 30 s; else unknown_kid |
| IdP down | Serve the last good set up to 24 h stale; then keys_unavailable |
| Burst of forged kids | At most one fetch per 30 s, however many requests |
If an internal issuer uses HS256, give it a separate validator whose key is a secret, compare the HMAC with timingSafeEqual after a length check, and never put HMAC secrets and public keys in one keyring. Mixing them is how HS256 signed with the RSA public key gets accepted.
05Tests that prove the property
import { describe, it, expect, vi } from "vitest";
import { generateKeyPairSync, sign } from "node:crypto";
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url");
const mint = (payload: object, header: object = { alg: "EdDSA", kid: "k1", typ: "JWT" }) => {
const input = b64(header) + "." + b64(payload);
return input + "." + sign(null, Buffer.from(input), privateKey).toString("base64url");
};
const T0 = 1_800_000_000;
let now = T0;
const clock = { nowSec: () => now };
const ISS = "https://idp.example";
const good = { iss: ISS, sub: "u1", aud: "academy-api", iat: T0, exp: T0 + 300 };
const code = (r: Result) => (r.ok ? "ok" : r.error.code);
function setup() {
const fetchKeys = vi.fn(async () => [{ kid: "k1", alg: "EdDSA" as const, key: publicKey }]);
const keys = new JwksCache({ fetch: fetchKeys }, ISS, clock);
const v = new Validator({ issuer: ISS, audience: "academy-api", algs: ["EdDSA"], skewSec: 60 }, keys, clock);
return { v, fetchKeys };
}
describe("Validator", () => {
it("accepts a well-formed token", async () => {
now = T0;
expect(code(await setup().v.validate(mint(good)))).toBe("ok");
});
it("rejects alg none even with a valid payload", async () => {
now = T0;
const unsigned = b64({ alg: "none", kid: "k1" }) + "." + b64(good) + ".";
expect(code(await setup().v.validate(unsigned))).toBe("unsupported_alg");
});
it("honours skew at the exp boundary", async () => {
const { v } = setup();
now = good.exp + 59;
expect(code(await v.validate(mint(good)))).toBe("ok");
now = good.exp + 60;
expect(code(await v.validate(mint(good)))).toBe("expired");
});
it("rejects a token for another audience", async () => {
now = T0;
expect(code(await setup().v.validate(mint({ ...good, aud: ["billing"] })))).toBe("wrong_audience");
});
it("a burst of unknown kids costs one fetch", async () => {
now = T0;
const { v, fetchKeys } = setup();
const forged = mint(good, { alg: "EdDSA", kid: "nope" });
const results = await Promise.all(Array.from({ length: 50 }, () => v.validate(forged)));
expect(results.every((r) => code(r) === "unknown_kid")).toBe(true);
expect(fetchKeys).toHaveBeenCalledTimes(1);
});
});The tests that matter are the hostile ones: alg: none, the exact skew boundary, the wrong audience and the fetch storm. Add one for a token whose header names RS256 while the key is Ed25519; it must fail as unsupported_alg, not reach the crypto call.
06Extensions, follow-ups, how to say it
- Token type: require
typ: at+jwt(RFC 9068) or a distinct audience so an ID token cannot be replayed as an access token. - Replay: for one-time tokens, record
jtiin a store with TTL equal to the remaining lifetime and reject repeats. - Revocation: short lifetimes (5 to 15 minutes) plus a small deny list of
jtiorsubchecked after validation. - Multi-issuer: one validator per issuer, chosen by the configured route, not by the unverified
iss.
Q1Why not take the JWKS URL from the token's iss or jku header?+
Then the attacker hosts the keys. The issuer and its JWKS URL are configuration; the token only names a kid within that set.
Q2The IdP rotated and a valid token has a new kid. What happens?+
The kid misses, the last attempt is older than 30 s, so one refresh runs, the new key appears and validation succeeds. The next requests hit the cache.
Q3Why verify the signature before decoding the payload?+
Everything in the payload is attacker controlled until the signature holds. Decoding first invites parser bugs and lets error messages leak which claims an attacker got wrong.
"The token never chooses how it's verified: algorithms are pinned in config and must match the key's own alg and type, so none and HS256-with-the-public-key die at the header. Keys come from the configured issuer's JWKS through a single-flight cache that refreshes on TTL or on an unknown kid, rate limited so forged kids can't cause a fetch storm, and serves stale keys through an IdP outage up to a cap. Signature first, then issuer, audience and time claims with bounded skew, and every failure is a typed code the caller can act on."
A token's header says `alg: HS256` and the validator's pinned list is `["RS256"]`. Where should it fail?