Multi-recipient
When a single encrypted payload needs to reach more than one recipient — a team document, a broadcast message, a fanned-out webhook — the compact serialization isn't enough. It can only carry one encrypted CEK.
RFC 7516 §7.2 defines the General JSON Serialization:
{
"protected": "<base64url of shared protected header>",
"unprotected": { "kid-root": "2025-q1" },
"recipients": [
{ "header": { "alg": "RSA-OAEP-256", "kid": "alice" }, "encrypted_key": "..." },
{ "header": { "alg": "ECDH-ES+A256KW", "kid": "bob", "epk": {...} }, "encrypted_key": "..." }
],
"iv": "...",
"ciphertext": "...",
"tag": "..."
}
One shared CEK encrypts the payload once. The CEK is then wrapped per recipient with their own key and alg. Each recipient unwraps their own entry; the shared ciphertext is the same for everyone.
unjwt exposes two functions for this:
encryptMulti()— produce the General serialization.decryptMulti()— unwrap using one recipient's key, return the shared payload.
Both importable from unjwt/jwe (or unjwt).
encryptMulti
import { encryptMulti } from "unjwt/jwe";
const jwe = await encryptMulti(
{ doc: "quarterly numbers", quarter: "Q1" },
[
{ key: aliceRsaPublicJwk }, // alg inferred → RSA-OAEP-256
{ key: bobEcdhPublicJwk }, // alg inferred → ECDH-ES+A256KW
{ key: sharedAesKwJwk, header: { "x-route": "eu" } }, // alg → A256KW, extra per-recipient header
],
{
enc: "A256GCM",
expiresIn: "1h",
sharedUnprotectedHeader: { "kid-set": "2025-q1" }, // top-level `unprotected`
aad: new TextEncoder().encode("doc-id:42"), // external AAD binding (optional)
},
);
The returned value is a plain JSON object — stringify it before sending over the wire.
Per-recipient fields
| Field | Role |
|---|---|
key | Recipient's key. key.alg is required. |
header | Fields in the per-recipient header. Cannot set alg/enc here. |
ecdh | ECDH-ES overrides (ephemeral key, partyUInfo, partyVInfo). |
p2s, p2c | PBES2 parameters — only meaningful for a PBES2-keyed recipient. |
keyManagementIV | AES-GCMKW IV override. |
Forbidden algorithms
dir and bare ECDH-ES (without +A*KW) are forbidden in a multi-recipient envelope. Both require the recipient's key to be the CEK — which can't be shared across multiple recipients without collapsing their security. Attempting either throws ERR_JWE_ALG_FORBIDDEN_IN_MULTI.
The fix is to use the key-wrap variants instead:
ECDH-ES+A128KW/+A192KW/+A256KW— derives a KEK per recipient, wraps the shared CEK with it.
Three header tiers
A multi-recipient JWE has three places a header field can live:
protected — shared, signed-over (part of AAD). Contains enc, typ, cty, and any custom fields you pass in options.protectedHeader.
unprotected — shared, not part of AAD. Contains fields from options.sharedUnprotectedHeader (metadata that doesn't need cryptographic binding).
Per-recipient header — unshared, not part of AAD. Contains alg, kid, epk, p2s/p2c, and anything you pass in a recipient's header.
RFC 7516 §7.2.1 mandates disjointness: a parameter name cannot appear in more than one tier per recipient. Violations throw ERR_JWE_HEADER_PARAMS_NOT_DISJOINT.
External AAD
options.aad binds the ciphertext to context that isn't part of the envelope — a document hash, a request URL, a transaction ID. The content cipher's AAD becomes BASE64URL(protected) || '.' || BASE64URL(aad), so tampering with either side (protected header or external AAD) fails authentication on decrypt.
decryptMulti
import { decryptMulti } from "unjwt/jwe";
const { payload, recipientIndex, recipientHeader } = await decryptMulti(
jweFromWire, // parsed JSON object — not the stringified form
bobEcdhPrivateJwk,
);
decryptMulti tries each recipient entry until one unwrap succeeds, then decrypts the shared ciphertext with the unwrapped CEK. Compact tokens go through decrypt(); multi-recipient tokens always come through here.
Accepted serializations
Both General and Flattened are accepted — Flattened is auto-normalized to a single-recipient General envelope in memory before processing.
Key inputs
Same shape as decrypt(): a single key, a JWKSet, a lookup function, or a password string.
Strict matching
strictRecipientMatch: true skips any recipient whose header doesn't unambiguously match the provided key (by kid, or by kty/curve/length). If no recipient matches, throws ERR_JWE_NO_MATCHING_RECIPIENT — no trial-and-error fallback:
await decryptMulti(jwe, bobEcdhPrivateJwk, { strictRecipientMatch: true });
Result fields
Extends the compact decrypt result with:
recipientIndex: number— which entry ofrecipientsunwrapped.recipientHeader?— theheaderfield from that recipient entry.sharedUnprotectedHeader?— the top-levelunprotectedheader, if present.
Flattened form
For consumers that strictly expect Flattened serialization (single recipient, JSON shape):
import { encryptMulti, generalToFlattened } from "unjwt/jwe";
const general = await encryptMulti(payload, [singleRecipient], opts);
const flattened = generalToFlattened(general);
// { protected, unprotected?, header?, encrypted_key?, iv, ciphertext, tag, aad? }
Throws ERR_JWE_INVALID_SERIALIZATION if the input has zero or multiple recipients — Flattened is strictly single-recipient.
Use cases
- Team documents. One encrypted file, unlocked by any authorized team member's private key.
- Broadcast messages. Encrypt once, fan out to N recipients — bandwidth proportional to N wraps, not N ciphertexts.
- Revocable membership. Re-encrypt when the recipient set changes — anyone removed from
recipients[]can no longer decrypt future messages. - Hybrid public-key delivery. Some recipients hold RSA keys, others EC — mix freely with different
algper recipient.
How JWE multi-recipient differs from JWS multi-signature
They look similar but have substantively different shapes — see the matching JWS multi-signature page for the other angle. Highlights:
- Shared protected header vs. per-signer. JWE has one shared
protectedheader (withenc,typ, etc., part of AAD) and per-recipientheader. JWS has per-signer protected headers; nothing is shared. - Three header tiers vs. two. JWE adds the
unprotected(shared, non-authenticated) tier that JWS lacks. - Shared CEK. One random CEK is used for content encryption, and each recipient gets their own wrapped copy. This is why
dirand bareECDH-EScan't appear in multi. - External AAD. JWE binds to out-of-band context via
aad. JWS has no analog — signatures already cover the whole payload. - "First recipient wins" semantics. Decryption terminates as soon as one wrap succeeds. JWS signatures can all be checked independently.