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 ignoredfor (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 firstconst 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 formimport { readFileSync } from "node:fs";import { createHash, createPublicKey } from "node:crypto"; // RFC 7638: the required members only, in lexicographic order, no whitespaceconst 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
- RFC 7638 hashes only the required members of the key type:
crv,kty,x,yfor EC;e,kty,nfor RSA;crv,kty,xfor OKP.kid,use,algand private members never count. - The members are serialized in lexicographic order, without whitespace, and hashed with SHA-256; the thumbprint is the base64url of the digest.
calculateJwkThumbprint(jwk, "sha256")does all of this; for a PEM key,importSPKI(withextractable: true) plusexportJWKgives the JWK first.calculateJwkThumbprintUrireturns the RFC 9278 formurn:ietf:params:oauth:jwk-thumbprint:sha-256:…, used where a URI is expected (for examplecnfclaims).
Gotchas
- Hashing the whole JWK gives a different value —
7YlIRl8b…instead ofoKIywvGU…for the EC key here, becausekid,useandalgwere included. Only the required members count. - Member order matters:
{"kty":"EC","crv":…}hashes to1sHbQkY2…. In the hand-written version, the order comes from theREQUIREDlists, which are already sorted. - Any whitespace breaks it: a pretty-printed canonical form hashes to
Is8unpyu….JSON.stringifywithout 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 validkid.
Related recipes
- How to convert a PEM public key to a JWK and back in Node.js
- How to verify a JWT with a JWKS in Node.js
- How to compute a public key fingerprint in Node.js
- How to compute a JWK thumbprint (RFC 7638) in Python
- How to compute a JWK thumbprint (RFC 7638) in Go
- How to compute a JWK thumbprint (RFC 7638) in WebCrypto