import { createPublicKey, verify } from "node:crypto"; function verifyEs256(token, publicKeyPem) { const [header, payload, signature] = token.split("."); const { alg } = JSON.parse(Buffer.from(header, "base64url").toString()); if (alg !== "ES256") throw new Error(`expected ES256, got ${alg}`); const ok = verify( "sha256", Buffer.from(`${header}.${payload}`), // signed bytes: the ASCII text { key: createPublicKey(publicKeyPem), dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url"), // JWS sig: raw r‖s, 64 bytes ); if (!ok) throw new Error("invalid signature"); const claims = JSON.parse(Buffer.from(payload, "base64url").toString()); if (claims.exp && claims.exp < Date.now() / 1000) throw new Error("token expired"); return claims;}How it works
- Pin the algorithm first: read
algfrom the header and requireES256. Never let the token choose the algorithm for you. - The signature covers the base64url *text*
header.payload— sign-input is those ASCII bytes, not the decoded JSON. dsaEncoding: "ieee-p1363"makesverifyaccept the 64-byte raw signature directly — no DER conversion step.- Node's
Buffer.from(str, "base64url")handles the unpadded base64url encoding JWS mandates.
Gotchas
- Omitting the
algcheck invites algorithm-confusion: a token stampedHS256and "verified" with the public key as an HMAC secret passes some naive implementations. Pin ES256 and reject everything else. - Without
dsaEncodingthe verification always fails — node defaults to DER, and no valid JWT carries a DER signature. - Signature validity is not token validity:
exp,nbf,issandaudchecks are your job (the snippet showsexponly).