Consuming a JWKS endpoint
OAuth and OIDC providers (Auth0, Okta, Entra, Keycloak, GitHub, Google, …) publish their public keys as a JWKS — a JWK Set served from a well-known URL:
https://auth.example.com/.well-known/jwks.json
Your service fetches that document, caches it, and uses it to verify tokens issued by the provider. Each token's header carries a kid that matches one of the keys in the set.
The one-shot version
import { verify } from "unjwt/jws";
const jwks = await fetch("https://auth.example.com/.well-known/jwks.json").then((r) => r.json());
const { payload } = await verify(tokenFromProvider, jwks, {
algorithms: ["RS256", "ES256"],
issuer: "https://auth.example.com",
audience: "my-api",
});
unjwt reads the token's kid, finds the matching key in the set, and verifies. If kid is absent or doesn't match, it throws ERR_JWK_KEY_NOT_FOUND before any crypto runs.
algorithms when consuming third-party tokens. The library's inferred allowlist from a set of mixed-algorithm keys is broader than you probably want — being explicit stops a malicious issuer from downgrading to a weaker algorithm you'd otherwise accept.The production version — cached fetch
In a real service you'd cache the JWKS to avoid hitting the provider on every request:
interface JWKSCacheEntry {
jwks: { keys: JWK[] };
fetchedAt: number;
}
const jwksCache = new Map<string, JWKSCacheEntry>();
const JWKS_TTL_MS = 60 * 60 * 1000; // 1 hour
async function getJwks(issuer: string): Promise<{ keys: JWK[] }> {
const cached = jwksCache.get(issuer);
if (cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) {
return cached.jwks;
}
const url = new URL("/.well-known/jwks.json", issuer).toString();
const jwks = await fetch(url).then((r) => {
if (!r.ok) throw new Error(`JWKS fetch failed: ${r.status}`);
return r.json();
});
jwksCache.set(issuer, { jwks, fetchedAt: Date.now() });
return jwks;
}
Then verify via a lookup function so unjwt only pays the fetch cost when the kid isn't already cached:
import { verify } from "unjwt/jws";
import type { JWKLookupFunction } from "unjwt";
const ISSUER = "https://auth.example.com";
const lookup: JWKLookupFunction = async (header) => {
const jwks = await getJwks(ISSUER);
return jwks; // unjwt picks the matching kid from the set
};
const { payload } = await verify(tokenFromProvider, lookup, {
algorithms: ["RS256"],
issuer: ISSUER,
audience: "my-api",
});
Handling key rotation
When the provider rotates keys, the new key gets added to the JWKS and the old one sticks around for a grace period. Your service should:
Refresh the cache when an unknown kid is seen — don't wait for the TTL.
Keep serving requests during the refresh; old tokens may still be in flight.
A pattern for on-demand refresh:
const lookup: JWKLookupFunction = async (header) => {
let jwks = await getJwks(ISSUER);
// Fast path: kid is already in the cached set
if (header.kid && jwks.keys.some((k: any) => k.kid === header.kid)) {
return jwks;
}
// Unknown kid — bypass cache and re-fetch once
jwksCache.delete(ISSUER);
jwks = await getJwks(ISSUER);
return jwks;
};
Be careful not to loop if the fetched JWKS still doesn't have the kid — rate-limit the re-fetch (e.g. allow at most one per minute per issuer) to avoid a cache-busting attack where a malicious party sends tokens with random unknown kids.
Short example with a rate-limit
const LAST_REFRESH_FLOOR_MS = 60_000; // don't re-fetch more than once/minute per issuer
const lastRefresh = new Map<string, number>();
async function getJwksFresh(issuer: string) {
const now = Date.now();
const last = lastRefresh.get(issuer) ?? 0;
if (now - last > LAST_REFRESH_FLOOR_MS) {
jwksCache.delete(issuer);
lastRefresh.set(issuer, now);
}
return getJwks(issuer);
}
What exactly unjwt does
With verify(token, jwks):
Decodes and validates the protected header.
Reads the token's kid.
Looks up candidates in jwks.keys:
- If
kid is present, only keys with matching kid are candidates (usually one). - If
kid is absent, every key whose alg is compatible with the token is a candidate.
kid is present, only keys with matching kid are candidates (usually one).kid is absent, every key whose alg is compatible with the token is a candidate.Verifies against the first candidate; falls back to the next on failure.
If no candidate verifies, throws.
No retry logic on your side, no kid parsing on your side.
See also
- JWK Sets →
- JWS verifying → JWKSet automatic rotation
- Authentication basics → — for the single-service case.