Skip to content
ecdsa.com

JWK thumbprint · Node.js

How to compute a JWK thumbprint (RFC 7638) in Node.js

To compute a JWK thumbprint in Node.js, jose's calculateJwkThumbprint hashes the key's required members exactly as RFC 7638 defines, for EC, RSA and OKP keys alike. The same result takes ten lines of node:crypto. Both are checked against the published RFC 7638 and RFC 8037 thumbprints, from JWK files and from PEM keys.

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

jose.mjs — thumbprints of JWKs and of a PEM key, plus the RFC 9278 URI
import { readFileSync } from "node:fs";
import { calculateJwkThumbprint, calculateJwkThumbprintUri, exportJWK, importSPKI } from "jose";
// From a JWK: only the required members count; kid, use and alg are ignored
for (const file of ["ec.jwk.json", "rsa.jwk.json", "okp.jwk.json"]) {
const jwk = JSON.parse(readFileSync(file, "utf8"));
console.log(`${file}: ${await calculateJwkThumbprint(jwk, "sha256")}`);
}
// From a PEM public key: export it as a JWK first
const pem = readFileSync("ec.pub.pem", "utf8");
const jwk = await exportJWK(await importSPKI(pem, "ES256", { extractable: true }));
console.log(`ec.pub.pem: ${await calculateJwkThumbprint(jwk)}`);
console.log(await calculateJwkThumbprintUri(jwk)); // RFC 9278 URI form
crypto.mjs — the same computation with node:crypto only
import { readFileSync } from "node:fs";
import { createHash, createPublicKey } from "node:crypto";
// RFC 7638: the required members only, in lexicographic order, no whitespace
const REQUIRED = { EC: ["crv", "kty", "x", "y"], RSA: ["e", "kty", "n"], OKP: ["crv", "kty", "x"], oct: ["k", "kty"] };
function jwkThumbprint(jwk) {
const members = REQUIRED[jwk.kty];
if (!members) throw new Error(`unsupported kty ${jwk.kty}`);
const canonical = JSON.stringify(Object.fromEntries(members.map((name) => [name, jwk[name]])));
return createHash("sha256").update(canonical).digest("base64url");
}
for (const file of ["ec.pub.pem", "rsa.pub.pem", "okp.pub.pem"]) {
const jwk = createPublicKey(readFileSync(file)).export({ format: "jwk" });
console.log(`${file}: ${jwkThumbprint(jwk)}`);
}

How it works

  1. RFC 7638 hashes only the required members of the key type: crv, kty, x, y for EC; e, kty, n for RSA; crv, kty, x for OKP. kid, use, alg and private members never count.
  2. The members are serialized in lexicographic order, without whitespace, and hashed with SHA-256; the thumbprint is the base64url of the digest.
  3. calculateJwkThumbprint(jwk, "sha256") does all of this; for a PEM key, importSPKI (with extractable: true) plus exportJWK gives the JWK first.
  4. calculateJwkThumbprintUri returns the RFC 9278 form urn:ietf:params:oauth:jwk-thumbprint:sha-256:…, used where a URI is expected (for example cnf claims).

Gotchas

  • Hashing the whole JWK gives a different value — 7YlIRl8b… instead of oKIywvGU… for the EC key here, because kid, use and alg were included. Only the required members count.
  • Member order matters: {"kty":"EC","crv":…} hashes to 1sHbQkY2…. In the hand-written version, the order comes from the REQUIRED lists, which are already sorted.
  • Any whitespace breaks it: a pretty-printed canonical form hashes to Is8unpyu…. JSON.stringify without a spacing argument is exactly the RFC form.
  • The thumbprint is the base64url digest, not hex. Tools that show a0a232c2… display the same digest in hex, which is useful for comparing but not a valid kid.

Related recipes