# 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](https://www.rfc-editor.org/rfc/rfc7518#section-4.6).

## The two ECDH-ES modes

Both appear in `alg`:

<table>
<thead>
  <tr>
    <th>
      <code>
        alg
      </code>
    </th>
    
    <th>
      Key flow
    </th>
    
    <th>
      <code>
        enc
      </code>
      
       behavior
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        ECDH-ES
      </code>
    </td>
    
    <td>
      Derived secret <strong>
        is
      </strong>
      
       the CEK directly (like <code>
        dir
      </code>
      
      )
    </td>
    
    <td>
      <code>
        enc
      </code>
      
       required; content cipher consumes the secret
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ECDH-ES+A128KW
      </code>
      
       / <code>
        A192KW
      </code>
      
       / <code>
        A256KW
      </code>
    </td>
    
    <td>
      Derived secret becomes a KEK that <strong>
        wraps
      </strong>
      
       a random CEK
    </td>
    
    <td>
      Shared CEK is wrapped per recipient
    </td>
  </tr>
</tbody>
</table>

Plain `ECDH-ES` is the simplest; the key-wrapped variants are required for [multi-recipient](/jwt/jwe/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.

<tip>

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

</tip>

## One-time key setup

Each participant generates a key pair once and persists it:

```ts [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:

```ts [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:

```ts [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`](/jwt/jwe/multi-recipient):

```ts [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:

```ts [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:

```ts
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](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Ar3.pdf) §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":

```ts
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:

```ts
const token = await encrypt({ data: "x" }, bobPublicKey, {
  ecdh: { ephemeralKey: myPrecomputedKeypair },
});
```

<warning>

**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.

</warning>

## Supported curves

<table>
<thead>
  <tr>
    <th>
      Curve
    </th>
    
    <th>
      Notes
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        P-256
      </code>
    </td>
    
    <td>
      Default for <code>
        ECDH-ES*
      </code>
      
      . NIST curve. Widely supported.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        P-384
      </code>
    </td>
    
    <td>
      Larger, slower.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        P-521
      </code>
    </td>
    
    <td>
      Largest NIST curve available.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        X25519
      </code>
    </td>
    
    <td>
      Modern, constant-time, fast. Prefer for new systems.
    </td>
  </tr>
</tbody>
</table>

Pick a curve at generation time:

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

## See also

- [Encrypting →](/jwt/jwe/encrypting) — the full `encrypt()` surface.
- [Multi-recipient →](/jwt/jwe/multi-recipient) — General JSON Serialization.
- [Examples: end-to-end encryption →](/examples/end-to-end-encryption).
- [Key wrapping →](/jwk/wrapping) — `wrapKey`/`unwrapKey` in isolation, for custom protocols.
