# 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](https://www.rfc-editor.org/rfc/rfc7516#section-7.2) defines the **General JSON Serialization**:

```json
{
  "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()`](#encryptmulti) — produce the General serialization.
- [`decryptMulti()`](#decryptmulti) — unwrap using one recipient's key, return the shared payload.

Both importable from `unjwt/jwe` (or `unjwt`).

## `encryptMulti`

```ts [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

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Role
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        key
      </code>
    </td>
    
    <td>
      Recipient's key. <strong>
        <code>
          key.alg
        </code>
        
         is required.
      </strong>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        header
      </code>
    </td>
    
    <td>
      Fields in the per-recipient <code>
        header
      </code>
      
      . Cannot set <code>
        alg
      </code>
      
      /<code>
        enc
      </code>
      
       here.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ecdh
      </code>
    </td>
    
    <td>
      ECDH-ES overrides (ephemeral key, partyUInfo, partyVInfo).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        p2s
      </code>
      
      , <code>
        p2c
      </code>
    </td>
    
    <td>
      PBES2 parameters — only meaningful for a PBES2-keyed recipient.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        keyManagementIV
      </code>
    </td>
    
    <td>
      AES-GCMKW IV override.
    </td>
  </tr>
</tbody>
</table>

### 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:

<steps level="4">

#### **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`.

</steps>

[RFC 7516 §7.2.1](https://www.rfc-editor.org/rfc/rfc7516#section-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`

```ts [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:

```ts
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):

```ts [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](/jwt/jws/multi-signature) 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.
