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:
alg | Key flow | enc behavior |
|---|---|---|
ECDH-ES | Derived secret is the CEK directly (like dir) | enc required; content cipher consumes the secret |
ECDH-ES+A128KW / A192KW / A256KW | Derived secret becomes a KEK that wraps a random CEK | Shared 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.
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:
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:
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:
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:
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:
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 },
});
Supported curves
| Curve | Notes |
|---|---|
P-256 | Default for ECDH-ES*. NIST curve. Widely supported. |
P-384 | Larger, slower. |
P-521 | Largest NIST curve available. |
X25519 | Modern, constant-time, fast. Prefer for new systems. |
Pick a curve at generation time:
const keys = await generateJWK("ECDH-ES+A256KW", { namedCurve: "X25519" });
See also
- Encrypting → — the full
encrypt()surface. - Multi-recipient → — General JSON Serialization.
- Examples: end-to-end encryption →.
- Key wrapping → —
wrapKey/unwrapKeyin isolation, for custom protocols.