Skip to content
ecdsa.com

Sign an ES256 JWT · WebCrypto

How to sign a JWT with ES256 in WebCrypto

WebCrypto can sign a JWT with ES256 without any library: crypto.subtle.sign with ECDSA and SHA-256 already returns the 64-byte r‖s signature that JWS uses, so the only work is base64url encoding. The function below runs in browsers and in Node.js; the run checks its token with OpenSSL.

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

signEs256 — build and sign a compact JWS
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" block
async 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))}`;
}
sign a token with iss, sub, aud, iat and exp
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

  1. importKey("pkcs8", …, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]) takes the DER bytes of a BEGIN PRIVATE KEY block; the key stays non-extractable.
  2. TextEncoder turns the JSON into UTF-8 bytes, and base64url maps standard base64 to the URL-safe alphabet without padding.
  3. sign({ name: "ECDSA", hash: "SHA-256" }, key, …) signs the ASCII of header.payload and returns 64 bytes — no DER conversion needed.
  4. The token is the signing input plus a dot and the base64url signature.

Gotchas

  • The hash member is required: { name: "ECDSA" } alone throws TypeError ('hash' is required in 'EcdsaParams').
  • WebCrypto cannot import SEC 1 keys (BEGIN EC PRIVATE KEY): even as "pkcs8" they fail with DataError: Invalid keyData. Convert them to PKCS#8, or import the key as a JWK.
  • The curve in importKey must be the key's curve: a P-384 key imported as P-256 fails with DataError: 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.

Related recipes