const spki = await crypto.subtle.exportKey("spki", publicKey);const digest = await crypto.subtle.digest("SHA-256", spki); const hex = [...new Uint8Array(digest)] .map((b) => b.toString(16).padStart(2, "0")) .join(":");console.log(hex); // 21:2c:...:9d — same value OpenSSL computesfunction pemToBytes(pem) { const b64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));} const spkiDer = pemToBytes(publicKeyPem); // already DER — hash directlyconst digest2 = await crypto.subtle.digest("SHA-256", spkiDer);How it works
exportKey("spki", …)requires an extractable public key and returns the canonical DER as anArrayBuffer.subtle.digestaccepts the buffer directly; formatting to colon-hex or base64 happens after, in plain JS.- Given a PEM, skip import/export entirely: strip the armor, base64-decode, hash — the PEM body *is* the SPKI DER.
- Legacy tools sometimes display SHA-1 fingerprints;
subtle.digest("SHA-1", spkiDer)reproduces those for comparison — fine for matching, never for security decisions.
Gotchas
- Both
exportKeyanddigestreturn promises — a missingawaithandsdigesta Promise and throws a confusing type error two lines away from the actual mistake. - Hash the decoded bytes, not the base64 string:
digestoverTextEncoder-encoded PEM text yields a stable-looking but incompatible value. - For the base64 pin form,
btoa(String.fromCharCode(...new Uint8Array(digest)))is fine at 32 bytes — but remember it is the digest being base64'd, not the key.