ECDH-ES and end-to-end encryption

ECDH-ES & end-to-end encryption

ECDH-ES (Elliptic-Curve Diffie-Hellman Ephemeral Static) is JWE's public-key encryption scheme. It's the workhorse for end-to-end encryption (E2EE) between participants who each hold a key pair: the private key never leaves its owner, and the public key can be shared freely.

Defined in RFC 7518 §4.6.

The two ECDH-ES modes

Both appear in alg:

algKey flowenc behavior
ECDH-ESDerived secret is the CEK directly (like dir)enc required; content cipher consumes the secret
ECDH-ES+A128KW / A192KW / A256KWDerived secret becomes a KEK that wraps a random CEKShared CEK is wrapped per recipient

Plain ECDH-ES is the simplest; the key-wrapped variants are required for multi-recipient scenarios.

For single-recipient flows, both work. The key-wrapped variants are slightly larger (there's a wrapped CEK segment) but they separate key agreement from content encryption cleanly.

For single-recipient E2EE, use ECDH-ES+A256KW by default. It's compatible with the multi-recipient path if you later add more recipients.

One-time key setup

Each participant generates a key pair once and persists it:

key-setup.ts
import { generateJWK } from "unjwt/jwk";

// Run once per participant at account creation / app install
const keys = await generateJWK("ECDH-ES+A256KW", { kid: "alice-2025" });
// keys.privateKey — keep in secure storage, never transmit
// keys.publicKey  — distribute freely (e.g., publish at /.well-known/jwks.json)

Key distribution is out of band — registration, user profile page, a JWKS endpoint, a shared directory. It's not a cryptographic concern; you only need an authentic channel for the public key (otherwise a MITM could substitute their own).

Sending to one recipient

The common case — one-to-one encrypted messaging:

alice-to-bob.ts
import { encrypt, decrypt } from "unjwt/jwe";

// Sender (Alice) — holds Bob's public key ——————————————————
const token = await encrypt({ message: "Hello Bob!" }, bobPublicKey);
// `token` is a compact JWE string — send via any channel

// Recipient (Bob) — holds his private key —————————————————
const { payload } = await decrypt(token, bobPrivateKey);
console.log(payload.message); // "Hello Bob!"

That's it. unjwt generates a fresh ephemeral key pair per message internally on Alice's side, derives the shared secret, and writes the ephemeral public key (epk) into the JWE header so Bob can re-derive the same secret. Alice never holds Bob's private key; Bob never holds Alice's ephemeral private key (it's thrown away as soon as the message is encrypted).

Forward secrecy: even if Bob's long-term private key is later compromised, past ECDH-ES messages can't be decrypted because the ephemeral private key is gone.

Sending to multiple recipients — simple fan-out

Easiest approach: one independent token per recipient. Each is encrypted for exactly that person:

fan-out.ts
const recipients = [
  { name: "bob", publicKey: bobPublicKey },
  { name: "charlie", publicKey: charliePublicKey },
];

const tokens = await Promise.all(
  recipients.map(({ publicKey }) => encrypt({ message: "Hello team!" }, publicKey)),
);
// Deliver tokens[0] to Bob, tokens[1] to Charlie, etc.

For most use cases this is exactly what you want — it's clear, straightforward, and each token is independently verifiable in its delivery context. The only trade-off is bandwidth: N recipients means encrypting (and transmitting) the payload N times.

Sending to multiple recipients — shared ciphertext

When the payload is large or bandwidth matters, encrypt the payload once under a random CEK, then wrap that CEK individually per recipient. Everyone gets the same ciphertext plus their own wrapped key.

The high-level API for this is encryptMulti:

multi-recipient.ts
import { encryptMulti, decryptMulti } from "unjwt/jwe";

const jwe = await encryptMulti(
  { message: "Hello team!" },
  [{ key: bobEcdhPublicJwk }, { key: charlieEcdhPublicJwk }],
  { enc: "A256GCM" },
);
// jwe is a JSON object — stringify and deliver

Every recipient receives the same jwe.ciphertext, and their own entry in jwe.recipients[] carries a CEK wrapped just for them.

deriveSharedSecret — the raw KDF step

encrypt/decrypt handle the full ECDH + Concat KDF + (optional) key-wrap cycle internally. For lower-level protocols, deriveSharedSecret exposes just the KDF step — returning the raw derived bytes:

raw-kdf.ts
import { deriveSharedSecret } from "unjwt/jwk";

// Both sides independently derive the exact same bytes
const aliceView = await deriveSharedSecret(
  bobPublicKey,
  aliceEphemeralPrivateKey,
  "ECDH-ES+A256KW",
);
const bobView = await deriveSharedSecret(
  aliceEphemeralPublicKey, // Bob gets this from the token's `epk` header
  bobPrivateKey,
  "ECDH-ES+A256KW",
);
// aliceView and bobView are identical Uint8Arrays (32 bytes for A256KW)

Use this when you need the derived bytes themselves — not a wrapped key. Applications include:

  • Custom hybrid protocols that use the shared secret as input to another KDF.
  • Non-JWE wrapping schemes.
  • Verifying the key-agreement step in isolation (interop testing, debugging).

Signature:

deriveSharedSecret(
  publicKey: CryptoKey | JWK_EC_Public,
  privateKey: CryptoKey | JWK_EC_Private,
  alg: JWK_ECDH_ES | ContentEncryptionAlgorithm,
  options?: {
    keyLength?: number;
    partyUInfo?: Uint8Array<ArrayBuffer>;
    partyVInfo?: Uint8Array<ArrayBuffer>;
  },
): Promise<Uint8Array<ArrayBuffer>>

When alg is bare "ECDH-ES", the derived key length is ambiguous — pass options.keyLength explicitly (otherwise throws ERR_JWK_INVALID). For "ECDH-ES+A*KW" and content-encryption algs the length is inferred.

Parameters to know

apu / apv — agreement party info

From NIST SP 800-56A §5.8.1. Bind the derived key to specific sender (PartyUInfo) and recipient (PartyVInfo) identities so a key derived for "alice→bob" can't be reused as "alice→mallory":

const token = await encrypt({ data: "x" }, bobPublicKey, {
  ecdh: {
    partyUInfo: new TextEncoder().encode("alice@example.com"),
    partyVInfo: new TextEncoder().encode("bob@example.com"),
  },
});

unjwt writes these as apu and apv in the header. On decrypt, they're re-used in the KDF — a mismatch causes the wrong key to be derived and decryption to fail.

ephemeralKey — deterministic key agreement

By default unjwt generates a fresh ephemeral key pair on every encrypt. Override only for testing or specialized protocols where you need the ephemeral key to match an external artifact:

const token = await encrypt({ data: "x" }, bobPublicKey, {
  ecdh: { ephemeralKey: myPrecomputedKeypair },
});
Never reuse ephemeral keys across messages in production. The "E" in ECDH-ES stands for "ephemeral" — reuse destroys forward secrecy and can leak the private key via side channels.

Supported curves

CurveNotes
P-256Default for ECDH-ES*. NIST curve. Widely supported.
P-384Larger, slower.
P-521Largest NIST curve available.
X25519Modern, constant-time, fast. Prefer for new systems.

Pick a curve at generation time:

const keys = await generateJWK("ECDH-ES+A256KW", { namedCurve: "X25519" });

See also