Skip to content
ecdsa.com

ECDH key agreement · WebCrypto

How to derive a shared key with ECDH and HKDF in WebCrypto

WebCrypto derives a shared key with ECDH natively: deriveBits with { name: "X25519" } or { name: "ECDH" } computes the shared secret, and HKDF turns it into raw key bytes or straight into a non-extractable AES-GCM key. X25519 works in Chrome 133+, Firefox 130+, Safari 17+ and Node.js; P-256 works everywhere WebCrypto does.

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

import a PKCS#8 private key and the peer's SPKI public key, derive the secret
const pemToBytes = (pem) =>
Uint8Array.from(atob(pem.replace(/-----[^-]+-----|\s+/g, "")), (c) => c.charCodeAt(0));
// algorithm: { name: "X25519" } or { name: "ECDH", namedCurve: "P-256" }
async function sharedSecret(privateKeyPem, peerPublicKeyPem, algorithm) {
const privateKey = await crypto.subtle.importKey("pkcs8", pemToBytes(privateKeyPem), algorithm, false, ["deriveBits"]);
// A peer's public key gets no usages at all: [] (["deriveBits"] is a SyntaxError)
const publicKey = await crypto.subtle.importKey("spki", pemToBytes(peerPublicKeyPem), algorithm, false, []);
return new Uint8Array(await crypto.subtle.deriveBits({ name: algorithm.name, public: publicKey }, privateKey, 256));
}
HKDF-SHA256 to raw key bytes
const hkdfParams = {
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0), // a required member, but it may be empty
info: new TextEncoder().encode("AES-256-GCM key v1"),
};
// The derived bytes — what the other languages print
async function deriveKeyBytes(secret) {
const hkdfKey = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveBits"]);
return new Uint8Array(await crypto.subtle.deriveBits(hkdfParams, hkdfKey, 256));
}
HKDF straight to a non-extractable AES-GCM key
// In the browser, derive the AES key directly and keep its bytes out of JavaScript
async function deriveAesKey(secret) {
const hkdfKey = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveKey"]);
return crypto.subtle.deriveKey(hkdfParams, hkdfKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
}

How it works

  1. The private key imports with ["deriveBits"]; the peer's public key imports with an empty usage list [].
  2. deriveBits({ name, public: peerKey }, privateKey, 256) returns the 32-byte shared secret. For P-256 the algorithm is "ECDH" with namedCurve: "P-256"; for X25519 it is "X25519".
  3. The secret is imported as an "HKDF" key, and deriveBits with { name: "HKDF", hash, salt, info } produces the key bytes the other languages print.
  4. deriveKey with { name: "AES-GCM", length: 256 } yields the same key as a CryptoKey that page scripts can use but never read; the run checks that it decrypts what the raw bytes encrypt.

Gotchas

  • Giving the peer's public key ["deriveBits"] throws SyntaxError: Unsupported key usage for ECDH key (or for X25519 key). Public keys take no usages in ECDH.
  • salt is a required member of HkdfParams: leaving it out throws TypeError ('salt' is required in 'HkdfParams'). An empty Uint8Array gives the RFC 5869 default.
  • Mixing curves — an X25519 public key with an ECDH P-256 private key — rejects with InvalidAccessError: key algorithm mismatch.
  • Older browsers reject "X25519" with NotSupportedError. Where you must support them, P-256 ECDH is the portable choice, or fall back to a library such as @noble/curves.

Related recipes