Skip to content
ecdsa.com

PEM ⇄ JWK · WebCrypto

How to convert a PEM public key to a JWK and back in WebCrypto

WebCrypto converts a PEM public key to a JWK and back through importKey and exportKey: import the SPKI bytes as an extractable key and export it as "jwk", or import a JWK and export "spki" for the PEM. It works in every browser for P-256 and, where supported, for Ed25519; the run checks the RFC 7515 round trip byte for byte.

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

pemToJwk and jwkToPem with crypto.subtle
const ALGORITHMS = {
"P-256": { name: "ECDSA", namedCurve: "P-256" },
Ed25519: { name: "Ed25519" },
};
// PEM -> JWK: import the SPKI bytes as an extractable key, export as "jwk"
async function pemToJwk(pem, curve) {
const spki = Uint8Array.from(atob(pem.replace(/-----[^-]+-----|\s+/g, "")), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey("spki", spki, ALGORITHMS[curve], true, ["verify"]);
const { key_ops, ext, ...jwk } = await crypto.subtle.exportKey("jwk", key); // drop WebCrypto-only members
return jwk;
}
// JWK -> PEM: import the JWK, export SPKI, base64 with 64-character lines
async function jwkToPem(jwk) {
const key = await crypto.subtle.importKey("jwk", jwk, ALGORITHMS[jwk.crv], true, ["verify"]);
const spki = new Uint8Array(await crypto.subtle.exportKey("spki", key));
const b64 = btoa(String.fromCharCode(...spki)).match(/.{1,64}/g).join("\n");
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----\n`;
}

How it works

  1. The PEM body is base64 of the SPKI DER; importKey("spki", …, true, ["verify"]) reads it as an extractable key.
  2. exportKey("jwk", key) returns the JWK; the WebCrypto-specific key_ops and ext members are removed before it is used elsewhere.
  3. importKey("jwk", jwk, …) validates the JWK, and exportKey("spki", key) returns the DER that becomes the PEM body.
  4. match(/.{1,64}/g) wraps the base64 at 64 characters, the line length OpenSSL and every other library write.

Gotchas

  • Ed25519 keys export with "alg":"Ed25519" (the fully-specified algorithm name of RFC 9864), not the older "EdDSA". Libraries that only know EdDSA may reject such a JWK; drop or rewrite alg if the consumer is older.
  • The algorithm passed to importKey must match the key: a P-256 SPKI imported as ECDH gives a key that cannot verify signatures, and one imported with the wrong curve fails with DataError.
  • Only extractable keys can be exported: importKey(…, false, …) followed by exportKey fails with InvalidAccessError.
  • The exported JWK carries key_ops (["verify"]) and ext: true. Leaving them in a published JWKS restricts how other libraries may use the key.

Related recipes