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 secondsconst 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); 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
importPKCS8(pem, "ES256")reads theBEGIN PRIVATE KEYblock and checks that the key is on P-256, the curve ES256 requires.setProtectedHeadersetsalg,typandkid; the claim setters writeiss,sub,aud,iatandexp.- Times are whole seconds since the epoch. Computing
nowonce keepsexp - iatexactly 900 seconds. - Without a library, sign the ASCII text
header.payloadwithsign("sha256", …)anddsaEncoding: "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. SetdsaEncoding: "ieee-p1363". importPKCS8accepts PKCS#8 only: aBEGIN EC PRIVATE KEYfile throwsTypeError: "pkcs8" must be PKCS#8 formatted string. Convert it once withopenssl 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 keepaudandissstable, because the verifier on the other side should check all three.