Skip to content
ecdsa.com

Verify a JWT with JWKS · WebCrypto

How to verify a JWT with a JWKS in WebCrypto

WebCrypto can verify a JWT against a JWKS with no library: fetch the key set, pick the JWK whose kid matches the token header, import it with importKey("jwk"), and check the ES256 signature with crypto.subtle.verify — then validate iss, aud and exp yourself. The run serves a real JWKS over HTTPS and checks five tokens.

Tested against the WebCrypto API in WebCrypto API Node.js 24.22.0 (fetch and crypto.subtle, the interfaces browsers implement) — the same crypto.subtle interface browsers implement.

verifyAccessToken — key by kid, ES256 signature, then the claims
const JWKS_URL = "https://issuer.example.com/.well-known/jwks.json";
const ISSUER = "https://issuer.example.com";
const AUDIENCE = "api://orders";
const b64urlBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
const b64urlJson = (s) => JSON.parse(new TextDecoder().decode(b64urlBytes(s)));
let keySet; // cached JWKS
async function findKey(kid) {
for (const refresh of [false, true]) { // one refetch for an unknown kid
if (!keySet || refresh) keySet = await (await fetch(JWKS_URL)).json();
const jwk = keySet.keys.find((k) => k.kid === kid && k.kty === "EC" && k.crv === "P-256" && (k.use ?? "sig") === "sig");
if (jwk) return crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
}
throw new Error(`no key with kid "${kid}" in the JWKS`);
}
async function verifyAccessToken(token) {
const [h, p, s] = token.split(".");
const header = b64urlJson(h);
if (header.alg !== "ES256") throw new Error(`unexpected alg ${header.alg}`); // pin the algorithm
const key = await findKey(header.kid);
const ok = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" }, key, b64urlBytes(s), new TextEncoder().encode(`${h}.${p}`),
);
if (!ok) throw new Error("invalid signature");
const claims = b64urlJson(p);
const now = Math.floor(Date.now() / 1000);
if (claims.iss !== ISSUER) throw new Error(`unexpected iss ${claims.iss}`);
if (![].concat(claims.aud).includes(AUDIENCE)) throw new Error(`unexpected aud ${claims.aud}`);
if (typeof claims.exp !== "number" || claims.exp <= now) throw new Error(`expired at ${claims.exp}`);
return claims;
}
check every token
for (const [label, token] of tokens) {
try {
const claims = await verifyAccessToken(token);
console.log(`${label}: accepted, sub=${claims.sub}`);
} catch (err) {
console.log(`${label}: rejected: ${err.message}`);
}
}

How it works

  1. findKey caches the JWKS and refetches it once when a kid is not in the cache — enough to follow a key rotation.
  2. A JWK imports directly: importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]). The filter also checks kty, crv and use, so an encryption key or a key for another curve is never used.
  3. The JWS signature is already the 64-byte form crypto.subtle.verify expects; the signed data is the ASCII of header.payload.
  4. Claims are checked only after the signature: issuer, audience (a string or an array) and exp.

Gotchas

  • Check alg before anything else. A token header is attacker-controlled; if the code picked the algorithm from it, a token could ask to be verified another way.
  • Without a cache, findKey would download the JWKS for every token. With a cache and no refetch, the first key rotation breaks every new token — keep both, and limit how often an unknown kid may trigger a fetch.
  • Fetching a JWKS from browser code needs CORS headers on the issuer's endpoint. Many identity providers send them for jwks.json; if not, verification belongs on your server.
  • Verifying in the browser is useful for display and debugging; access decisions must still be made by the server that receives the token.

Related recipes