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

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

Always pass 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:

jwks-cache.ts
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:

verify-with-cache.ts
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:

jwks-smart-cache.ts
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

rate-limited-refresh.ts
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.

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