const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");const jsonPart = (value) => base64url(new TextEncoder().encode(JSON.stringify(value))); // pkcs8: the DER bytes of a "BEGIN PRIVATE KEY" blockasync function signEs256(pkcs8, kid, claims) { const key = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); const signingInput = `${jsonPart({ alg: "ES256", typ: "JWT", kid })}.${jsonPart(claims)}`; const signature = await crypto.subtle.sign( { name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(signingInput), ); // WebCrypto already returns r‖s (64 bytes), exactly the JWS format return `${signingInput}.${base64url(new Uint8Array(signature))}`;}const now = Math.floor(Date.now() / 1000);const token = await signEs256(pkcs8, "2026-10", { iss: "https://issuer.example.com", sub: "user-42", aud: "api://orders", iat: now, exp: now + 15 * 60,});console.log(token);How it works
importKey("pkcs8", …, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"])takes the DER bytes of aBEGIN PRIVATE KEYblock; the key stays non-extractable.TextEncoderturns the JSON into UTF-8 bytes, andbase64urlmaps standard base64 to the URL-safe alphabet without padding.sign({ name: "ECDSA", hash: "SHA-256" }, key, …)signs the ASCII ofheader.payloadand returns 64 bytes — no DER conversion needed.- The token is the signing input plus a dot and the base64url signature.
Gotchas
- The
hashmember is required:{ name: "ECDSA" }alone throwsTypeError('hash' is required in 'EcdsaParams'). - WebCrypto cannot import SEC 1 keys (
BEGIN EC PRIVATE KEY): even as"pkcs8"they fail withDataError: Invalid keyData. Convert them to PKCS#8, or import the key as a JWK. - The curve in
importKeymust be the key's curve: a P-384 key imported as P-256 fails withDataError: Named curve mismatch. - Keep private keys out of front-end code that ships to users. Signing in the browser makes sense for keys the user holds — generated in the page, or a passkey-like flow — not for a server's issuing key.