ecdsa.com

Verify an ES256 JWT · WebCrypto

How to verify an ES256 JWT in WebCrypto

WebCrypto is the natural home for ES256: the JWS signature is raw r‖s, which is the only format crypto.subtle.verify accepts — so a browser verifies an ES256 JWT with zero dependencies and zero format conversion.

Tested against the WebCrypto API in Node.js 26 — the same crypto.subtle interface browsers implement.

Same recipe in:Node.jsPythonGoWebCrypto
verify-jwt.js — ES256 verification in the browser
const b64urlToBytes = (s) => {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
};
 
async function verifyEs256(token, spkiDer) { // spkiDer: Uint8Array (SPKI)
const [header, payload, signature] = token.split(".");
 
const { alg } = JSON.parse(new TextDecoder().decode(b64urlToBytes(header)));
if (alg !== "ES256") throw new Error(`expected ES256, got ${alg}`);
 
const key = await crypto.subtle.importKey(
"spki", spkiDer,
{ name: "ECDSA", namedCurve: "P-256" },
false, ["verify"],
);
 
const ok = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
key,
b64urlToBytes(signature), // raw r‖s — WebCrypto native
new TextEncoder().encode(`${header}.${payload}`),
);
if (!ok) throw new Error("invalid signature");
 
const claims = JSON.parse(new TextDecoder().decode(b64urlToBytes(payload)));
if (claims.exp && claims.exp < Date.now() / 1000) throw new Error("token expired");
return claims;
}

How it works

  1. Decode base64url by mapping the URL-safe alphabet back to standard base64 and re-adding padding — atob alone rejects the raw JWS segments in some engines.
  2. Pin alg to ES256 before importing the key; the token's header is attacker-controlled input.
  3. The verified bytes are the ASCII of header.payload (the undecoded text); the signature slots into subtle.verify untouched, because JWS and WebCrypto share the raw format.

Gotchas

  • This same-format convenience flips for other stacks: forward the token's signature to OpenSSL or Python and it now needs raw → DER conversion — the mirror image of this page.
  • ES256 means P-256 + SHA-256, exactly. Importing the key as P-384 or verifying with SHA-384 fails; for ES384 tokens change both parameters together.
  • A JWK public key ({ kty: "EC", crv: "P-256", x, y }) imports more directly: importKey("jwk", jwk, …) — no SPKI bytes needed. Useful when keys arrive from a JWKS endpoint.

Related recipes