// 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"] }; 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(/=+$/, "");}// A CryptoKey (generated, or imported from SPKI) exports its public JWK when extractableasync 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
REQUIREDholds the members per key type in sorted order; building the object in that order is enough, becauseJSON.stringifykeeps insertion order for string keys.crypto.subtle.digest("SHA-256", …)hashes the UTF-8 bytes of the canonical JSON.btoaplus the+/→-_mapping and padding removal turns the digest into base64url.- A CryptoKey's public JWK comes from
exportKey("jwk", key); keys generated in the page are a common source, for example to set akid.
Gotchas
exportKeyworks only on extractable keys: a key imported or generated withextractable: falsethrowsInvalidAccessError. Public keys fromgenerateKeyare always extractable.- A JWK exported from WebCrypto also contains
key_opsandext. 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.subtleexists only in secure contexts (HTTPS or localhost); on a plain-HTTP page it isundefined.