Skip to content
ecdsa.com

JWK thumbprint · WebCrypto

How to compute a JWK thumbprint (RFC 7638) in WebCrypto

WebCrypto computes a JWK thumbprint with crypto.subtle.digest over the RFC 7638 canonical JSON: the required members of the key type in lexicographic order, without whitespace. A CryptoKey exports its public JWK first. The browser function reproduces the published RFC 7638 and RFC 8037 thumbprints.

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

jwkThumbprint — RFC 7638 with crypto.subtle.digest
// 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"] };
async function jwkThumbprint(jwk) {
const members = REQUIRED[jwk.kty];
if (!members) throw new Error(`unsupported kty ${jwk.kty}`);
// JSON.stringify keeps insertion order, so building the object in sorted order is enough
const canonical = JSON.stringify(Object.fromEntries(members.map((name) => [name, jwk[name]])));
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)));
return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
the thumbprint of a CryptoKey, via exportKey("jwk")
// A CryptoKey (generated, or imported from SPKI) exports its public JWK when extractable
async function cryptoKeyThumbprint(publicKey) {
return jwkThumbprint(await crypto.subtle.exportKey("jwk", publicKey));
}
const spki = Uint8Array.from(atob(ecPublicKeyPem.replace(/-----[^-]+-----|\s+/g, "")), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
console.log(`ec.pub.pem: ${await cryptoKeyThumbprint(key)}`);

How it works

  1. REQUIRED holds the members per key type in sorted order; building the object in that order is enough, because JSON.stringify keeps insertion order for string keys.
  2. crypto.subtle.digest("SHA-256", …) hashes the UTF-8 bytes of the canonical JSON.
  3. btoa plus the +/ → -_ mapping and padding removal turns the digest into base64url.
  4. A CryptoKey's public JWK comes from exportKey("jwk", key); keys generated in the page are a common source, for example to set a kid.

Gotchas

  • exportKey works only on extractable keys: a key imported or generated with extractable: false throws InvalidAccessError. Public keys from generateKey are always extractable.
  • A JWK exported from WebCrypto also contains key_ops and ext. They must not enter the thumbprint, which is why only the required members are copied.
  • JSON.stringify(jwk) of the whole object is never the thumbprint input, even after sorting: the extra members change the hash.
  • crypto.subtle exists only in secure contexts (HTTPS or localhost); on a plain-HTTP page it is undefined.

Related recipes