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:
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:
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:
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:
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:
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.
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:
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.ephemeralKeyunless you understand the consequences.