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:

Both importable from unjwt/jwe (or unjwt).

encryptMulti

encrypt-multi.ts
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

FieldRole
keyRecipient's key. key.alg is required.
headerFields in the per-recipient header. Cannot set alg/enc here.
ecdhECDH-ES overrides (ephemeral key, partyUInfo, partyVInfo).
p2s, p2cPBES2 parameters — only meaningful for a PBES2-keyed recipient.
keyManagementIVAES-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

decrypt-multi.ts
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 of recipients unwrapped.
  • recipientHeader? — the header field from that recipient entry.
  • sharedUnprotectedHeader? — the top-level unprotected header, if present.

Flattened form

For consumers that strictly expect Flattened serialization (single recipient, JSON shape):

flattened.ts
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 alg per 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 protected header (with enc, typ, etc., part of AAD) and per-recipient header. 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 dir and bare ECDH-ES can'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.