const spki = await crypto.subtle.exportKey("spki", publicKey); const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));const pem = `-----BEGIN PUBLIC KEY-----\n${ b64.match(/.{1,64}/g).join("\n")}\n-----END PUBLIC KEY-----\n`;console.log(pem);// exportKey("spki", privateKey) throws — SPKI is public-only.// Route through JWK and drop the private scalar d:const jwk = await crypto.subtle.exportKey("jwk", privateKey);delete jwk.d;jwk.key_ops = ["verify"];const publicKey = await crypto.subtle.importKey( "jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"],);How it works
exportKey("spki", …)works only on public keys and only if the key was created withextractable: true.- PEM formatting is mechanical: base64 the DER, wrap at 64 columns, add the
PUBLIC KEYheader and footer, end with a newline. - To publish the public half of a private
CryptoKey, export as JWK, deleted, re-import as a public key, then export SPKI (second snippet).
Gotchas
String.fromCharCode(...bytes)blows the argument limit on huge buffers; EC SPKI is ~91 bytes so it is safe here, but reuse this pattern for certificates and it will bite — chunk instead.- A non-extractable key cannot be exported at all —
exportKeyrejects withInvalidAccessError. Extractability is decided atgenerateKey/importKeytime and cannot be flipped later. - When deriving public from private via JWK, forgetting to delete
dre-imports a *private* key — and anything you then publish is the private key in JWK clothing. Deleted, always.