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) — 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:

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:

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:

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.

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:

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 to encrypt once with a random CEK, then wrap that CEK separately per recipient:

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:

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.

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.

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:

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:

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