Skip to content
ecdsa.com

Sign an ES256 JWT · Node.js

How to sign a JWT with ES256 in Node.js

To sign a JWT with ES256 in Node.js, the jose library's SignJWT builds the header and claims and signs with a P-256 key imported from PEM. The same token can be produced with node:crypto alone if the signature is requested in the 64-byte JWS form; the run checks both tokens with OpenSSL against the public key.

Tested with jose 6.2.13 (Node.js 24.22.0) and Node.js 24.22.0 (node:crypto, OpenSSL 3.5.8).

sign.mjs — ES256 with jose and a PKCS#8 key
import { readFileSync } from "node:fs";
import { SignJWT, importPKCS8 } from "jose";
const privateKey = await importPKCS8(readFileSync("key.pem", "utf8"), "ES256");
const now = Math.floor(Date.now() / 1000); // JWT times are whole seconds
const token = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", typ: "JWT", kid: "2026-10" })
.setIssuer("https://issuer.example.com")
.setSubject("user-42")
.setAudience("api://orders")
.setIssuedAt(now)
.setExpirationTime(now + 15 * 60)
.sign(privateKey);
console.log(token);
the same token with node:crypto only
import { readFileSync } from "node:fs";
import { createPrivateKey, sign } from "node:crypto";
const part = (json) => Buffer.from(JSON.stringify(json)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const header = part({ alg: "ES256", typ: "JWT", kid: "2026-10" });
const payload = part({ iss: "https://issuer.example.com", sub: "user-42", aud: "api://orders", iat: now, exp: now + 900 });
const signature = sign("sha256", Buffer.from(`${header}.${payload}`), {
key: createPrivateKey(readFileSync("key.pem")),
dsaEncoding: "ieee-p1363", // JWS needs r‖s (64 bytes), not the DER default
});
console.log(`${header}.${payload}.${signature.toString("base64url")}`);

How it works

  1. importPKCS8(pem, "ES256") reads the BEGIN PRIVATE KEY block and checks that the key is on P-256, the curve ES256 requires.
  2. setProtectedHeader sets alg, typ and kid; the claim setters write iss, sub, aud, iat and exp.
  3. Times are whole seconds since the epoch. Computing now once keeps exp - iat exactly 900 seconds.
  4. Without a library, sign the ASCII text header.payload with sign("sha256", …) and dsaEncoding: "ieee-p1363", then base64url-encode the 64-byte result.

Gotchas

  • node:crypto returns a DER signature by default (71 bytes in a test run, usually 70–72). A token carrying it fails every verifier — jose reports only ERR_JWS_SIGNATURE_VERIFICATION_FAILED: signature verification failed. Set dsaEncoding: "ieee-p1363".
  • importPKCS8 accepts PKCS#8 only: a BEGIN EC PRIVATE KEY file throws TypeError: "pkcs8" must be PKCS#8 formatted string. Convert it once with openssl pkcs8 -topk8 -nocrypt -in ec.pem -out key.pem.
  • The algorithm must match the curve: importing a P-384 key for ES256, or a P-256 key for ES384, fails with DataError: Named curve mismatch.
  • Signing is not the same as issuing safely: always set exp, and keep aud and iss stable, because the verifier on the other side should check all three.

Related recipes