# End-to-end encryption

> 

In an **end-to-end encrypted** (E2EE) system, each participant holds a **key pair**: the private key lives on their device and never leaves; the public key is shared openly. Senders encrypt with the recipient's public key; only the recipient's private key can decrypt.

unjwt's public-key encryption uses **ECDH-ES** ([RFC 7518 §4.6](https://www.rfc-editor.org/rfc/rfc7518#section-4.6)) — elliptic-curve Diffie-Hellman with ephemeral keys — with optional AES Key Wrap.

## Key setup — once per participant

Every participant generates their key pair once (at account creation, app install, etc.) and persists it:

```ts [setup-alice.ts]
import { generateJWK } from "unjwt/jwk";

// Alice generates her key pair
const aliceKeys = await generateJWK("ECDH-ES+A256KW", { kid: "alice-2025" });
// aliceKeys.privateKey — store securely on Alice's device
// aliceKeys.publicKey  — publish openly (e.g. at /.well-known/jwks.json)
```

The public key is shared out-of-band — through a user profile page, a JWKS endpoint, a shared directory, a QR code at registration. **Cryptographically**, the system only needs authentic delivery of the public key; it doesn't need to be secret.

## One-to-one — sending a private message

Alice wants to send a message only Bob can read:

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

// ── On Alice's device ──
const token = await encrypt(
  { message: "Hello Bob! Meeting at 3pm.", from: "alice" },
  bobPublicKey, // fetched from Bob's profile
);
// `token` is a compact JWE string. Send via any channel — email, HTTP, etc.

// ── On Bob's device ──
const { payload } = await decrypt(token, bobPrivateKey);
console.log(payload);
// { message: "Hello Bob! Meeting at 3pm.", from: "alice" }
```

Under the hood, unjwt:

<steps level="4">

#### Generates a fresh **ephemeral key pair** on Alice's side (one-time use).

#### Derives a shared secret from `ephemeralPrivate × bobPublic` (ECDH).

#### Uses the shared secret as a KEK to wrap a random CEK.

#### Encrypts the payload with the CEK (AES-GCM).

#### Writes the ephemeral public key (`epk`) into the JWE header.

</steps>

Bob re-derives the same shared secret using `bobPrivate × ephemeralPublic` (from the `epk` header), unwraps the CEK, decrypts.

The ephemeral private key is **discarded after encryption** — this is where **forward secrecy** comes from. Even if Bob's long-term key is stolen later, past messages can't be decrypted because the ephemeral private key is gone.

## One-to-many — simple fan-out

The easiest way to reach multiple recipients is one token per recipient:

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

const tokens = await Promise.all(
  recipients.map(({ publicKey }) => encrypt({ msg: "Team update" }, publicKey)),
);

// Deliver each token to its intended recipient
await Promise.all(recipients.map(({ name }, i) => deliver(name, tokens[i])));
```

Each recipient decrypts their own token independently. The trade-off is bandwidth: the payload is encrypted N times.

## One-to-many — shared ciphertext

When the payload is large (documents, media, …), re-encrypting per recipient is wasteful. Use [`encryptMulti`](/jwt/jwe/multi-recipient) to encrypt **once** with a random CEK, then wrap that CEK separately per recipient:

```ts [multi.ts]
import { encryptMulti, decryptMulti } from "unjwt/jwe";

const jwe = await encryptMulti(
  { msg: "Team update", size: "100KB" },
  [{ key: bobPublicKey }, { key: charliePublicKey }, { key: davePublicKey }],
  { enc: "A256GCM" },
);

// `jwe` is a JSON object — stringify and deliver the same structure to everyone.
// Each recipient uses their OWN private key to decrypt.
```

On the recipient side:

```ts [multi-decrypt.ts]
const { payload, recipientIndex } = await decryptMulti(jwe, myPrivateKey);
console.log(payload); // { msg: "Team update", size: "100KB" }
console.log(recipientIndex); // which entry of jwe.recipients[] matched your key
```

Everyone receives the same `jwe.ciphertext` segment; each recipient's `encrypted_key` is wrapped for their key alone.

<tip>

Use **fan-out** (one token per recipient) for small messages to a few recipients — simpler, cleaner isolation.

Use **shared ciphertext** (`encryptMulti`) for large payloads or wide fanout — much less bandwidth, and delivery can piggyback on the same transport.

</tip>

## What if I need the raw shared secret?

The layered "derive → wrap → encrypt" flow above is what `encrypt()` handles internally. If you need just the **derived shared secret** — for a custom hybrid protocol, a non-JWE wrapper, or interop testing — use [`deriveSharedSecret`](/jwt/jwe/ecdh-es#derivesharedsecret-the-raw-kdf-step):

```ts [raw-secret.ts]
import { deriveSharedSecret } from "unjwt/jwk";

const sharedBytes = await deriveSharedSecret(theirPublicKey, myPrivateKey, "ECDH-ES+A256KW");
// sharedBytes: Uint8Array of derived material, ready for any cipher you like
```

## Curves — picking one

`generateJWK("ECDH-ES+A256KW")` defaults to `P-256` (NIST curve). For new systems, consider `X25519`:

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

X25519 is modern, constant-time, and compact. It's interoperable with any unjwt consumer — the JWE header carries the curve identifier so the recipient uses the right math automatically.

## Security notes

- **Public keys must be authentic.** Nothing unjwt (or any crypto library) can do protects against a MITM who substitutes their own public key before delivery. Use TLS, code signing, or out-of-band verification (e.g. safety-number comparison) to establish authenticity.
- **Private keys stay on-device.** Never transmit or serialize them to untrusted storage. Use a secure enclave / keychain where available.
- **Forward secrecy depends on ephemeral-key discarding.** unjwt generates them internally and drops references — don't pass your own long-term key as `options.ecdh.ephemeralKey` unless you understand the consequences.

## See also

- [JWE ECDH-ES →](/jwt/jwe/ecdh-es)
- [JWE multi-recipient →](/jwt/jwe/multi-recipient)
- [JWK generation →](/jwk/generating)
