Multi-recipient

Encrypt a payload once and let several recipients open it, each with their own key, in a JWE JSON object.

import { encryptMulti, decryptMulti } from "unjwt/jwe";
import { generateJWK } from "unjwt/jwk";

const alice = await generateJWK("RSA-OAEP-256", { kid: "alice" });
const bob = await generateJWK("ECDH-ES+A256KW", { kid: "bob" });
const backup = await generateJWK("A256KW", { kid: "backup" });

const jwe = await encryptMulti(
  { doc: "q3-report", level: "confidential" },
  [{ key: alice.publicKey }, { key: bob.publicKey }, { key: backup, header: { "x-route": "eu" } }],
  { sharedUnprotectedHeader: { app: "reports" }, aad: "doc:q3-report" },
);

const { payload, recipientIndex } = await decryptMulti(jwe, bob.privateKey);
// payload: { doc: "q3-report", level: "confidential" }, recipientIndex: 1

One random content key encrypts the payload once. That content key is then wrapped separately for each recipient with that recipient's alg. Whoever can unwrap any one copy can read the payload. The compact form holds a single recipient, so this uses the JSON serialization (RFC 7516 §7.2).

#Signatures

function encryptMulti(
  payload: string | Uint8Array | Record<string, unknown>,
  recipients: JWEMultiRecipient[],
  options?: JWEMultiEncryptOptions,
): Promise<JWEGeneralSerialization>;

interface JWEMultiRecipient {
  key: JWEEncryptJWK; // a JWK with `alg`; its `kid` is copied into the header
  header?: JWEHeaderParameters; // per-recipient, not integrity protected
  p2s?: Uint8Array;
  p2c?: number;
  keyManagementIV?: Uint8Array;
  ecdh?: { ephemeralKey?; partyUInfo?: Uint8Array; partyVInfo?: Uint8Array };
}

function decryptMulti<T = JWTClaims>(
  jwe: JWEGeneralSerialization | JWEFlattenedSerialization,
  key: JWEDecryptJWK | JWKSet | CryptoKey | Uint8Array | string | JWEKeyLookup,
  options?: JWEDecryptOptions & {
    strictRecipientMatch?: boolean;
    limits?: { recipients?: number; keyCandidates?: number; pbes2Iterations?: number };
  },
): Promise<{
  payload: T;
  protectedHeader: JWEHeaderParameters;
  sharedUnprotectedHeader?: JWEHeaderParameters;
  recipientHeader?: JWEHeaderParameters;
  recipientIndex: number;
  cek?: Uint8Array;
  aad?: Uint8Array;
}>;

function generalToFlattened(jwe: JWEGeneralSerialization): JWEFlattenedSerialization;

#Options

encryptMulti takes the encrypt options that apply to the whole token: enc (default A256GCM), protectedHeader, the time options, cek and contentEncryptionIV. Per-recipient settings (p2s, p2c, keyManagementIV, ecdh) move onto each recipient. Two options are new:

OptionTypeDefaultWhat it does
sharedUnprotectedHeaderRecord<string, unknown>noneMembers for every recipient, written as unprotected. Not integrity protected.
aadstring | Uint8ArraynoneExtra data bound to the ciphertext and carried as aad (RFC 7516 §5.1). Empty means none.

decryptMulti takes every decrypt() option, plus strictRecipientMatch and limits.recipients (default 16). Pass the parsed object, never a string.

#The output

// jwe
{
  "protected": "eyJlbmMiOi…",          // {"enc":"A256GCM","typ":"JWT"}
  "unprotected": { "app": "reports" },
  "aad": "ZG9jOnEzLXJlcG9ydA",
  "recipients": [
    { "header": { "kid": "alice", "alg": "RSA-OAEP-256" }, "encrypted_key": "ddS6mVN3_x…" },
    { "header": { "kid": "bob", "epk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }, "alg": "ECDH-ES+A256KW" }, "encrypted_key": "x1rIp7NoYN…" },
    { "header": { "kid": "backup", "x-route": "eu", "alg": "A256KW" }, "encrypted_key": "Uf-vK-6Ftg…" }
  ],
  "iv": "emeCQkEYbB…",
  "ciphertext": "ngxEEPDg5A…",
  "tag": "cJfvu1Fphy…"
}

There are three header tiers:

TierHoldsIntegrity protected
protectedenc, typ, your protectedHeaderyes
unprotectedsharedUnprotectedHeaderno
recipients[].headeralg, kid, epk, p2s, p2c, iv, tag and your headerno

A name may appear in one tier only (RFC 7516 §7.2.1). Overlap is ERR_JWE_HEADER_PARAMS_NOT_DISJOINT. crit belongs in the protected tier: elsewhere it is ERR_INVALID_OPTION. zip is refused in every tier.

await encryptMulti({ a: 1 }, [{ key: backup }], { sharedUnprotectedHeader: { kid: "x" } });
// JWTError ERR_JWE_HEADER_PARAMS_NOT_DISJOINT: JWE header parameter "kid" appears in multiple header tiers; protected, shared unprotected, and per-recipient headers must be disjoint (RFC 7516 §7.2.1).

#Recipient keys

  • Each key is a JWK with an alg. A JWK without one, or a CryptoKey, is ERR_JWE_RECIPIENT_ALG_INFERENCE.
  • dir and bare ECDH-ES are refused with ERR_JWE_ALG_FORBIDDEN_IN_MULTI. Both make the recipient's own key the content key, which cannot be shared with other recipients. A CEK JWK (alg: "A256GCM") counts as dir. Use ECDH-ES+A*KW instead, or encrypt() for one recipient.
  • A PBES2 recipient with no p2c is minted at 600,000 iterations with a fresh 16-byte salt.
await encryptMulti({ a: 1 }, [{ key: await generateJWK("A256GCM") }]);
// JWTError ERR_JWE_ALG_FORBIDDEN_IN_MULTI: Recipient[0] resolves to "dir", which requires exactly one recipient. Use encrypt() instead.

#Decrypting

decryptMulti tries the recipients in order and returns the first one your key opens:

const result = await decryptMulti(jwe, bob.privateKey);
// {
//   payload: { doc: "q3-report", level: "confidential" },
//   protectedHeader: { enc: "A256GCM", typ: "JWT" },
//   recipientIndex: 1,
//   sharedUnprotectedHeader: { app: "reports" },
//   recipientHeader: { kid: "bob", epk: { … }, alg: "ECDH-ES+A256KW" },
// }
  • protectedHeader is only what was protected. encryptMulti writes alg per recipient, so read it from recipientHeader.
  • enc is read from the protected or the shared unprotected header, never from a recipient's header.
  • recipients[] is outside the authenticated data, so anyone can add or rewrite an entry. A failure in one recipient (bad header, disallowed alg, failed unwrap) moves on to the next. A failure of the whole token (protected header, shared header, content segments, claims) is thrown at once.
  • options.typ is checked against the protected header only.
  • limits.pbes2Iterations is one budget for the whole call, so a pile of injected PBES2 recipients cannot multiply the work.

When no recipient opens, the last failure is thrown, usually ERR_JWE_DECRYPTION_FAILED. With strictRecipientMatch: true, a recipient is tried only when your key could open it (a fitting key type, size or curve; a kid on the key is a hint and never rules it out). Nothing matching is ERR_JWE_NO_MATCHING_RECIPIENT without any crypto.

const stranger = await generateJWK("A256KW", { kid: "stranger" });
await decryptMulti(jwe, stranger);
// JWTError ERR_JWE_DECRYPTION_FAILED: JWE decryption failed.
await decryptMulti(jwe, stranger, { strictRecipientMatch: true });
// JWTError ERR_JWE_NO_MATCHING_RECIPIENT: No recipient matched the provided key under strictRecipientMatch.

#Flattened form

encryptMulti always returns the General form. generalToFlattened lifts a single recipient's header and encrypted_key to the top level, and throws ERR_JWE_INVALID_SERIALIZATION for any other count. decryptMulti accepts both forms.

const flat = generalToFlattened(await encryptMulti({ sub: "u" }, [{ key: backup }]));
Object.keys(flat); // ["protected", "iv", "ciphertext", "tag", "header", "encrypted_key"]
(await decryptMulti(flat, backup)).payload; // { sub: "u" }

#Errors

CodeWhen
ERR_JWE_INVALIDNo recipients, more than limits.recipients, or the decrypt() token checks.
ERR_JWE_RECIPIENT_ALG_INFERENCEA recipient key that is not a JWK or has no alg.
ERR_JWE_ALG_FORBIDDEN_IN_MULTIA recipient resolves to dir or bare ECDH-ES.
ERR_JWE_HEADER_PARAMS_NOT_DISJOINTA name in more than one header tier.
ERR_JWE_INVALID_SERIALIZATIONNot an object, recipients mixed with top-level header or encrypted_key, or a flatten of more than one recipient.
ERR_JWE_NO_MATCHING_RECIPIENTstrictRecipientMatch found no candidate recipient.
ERR_INVALID_OPTIONcrit outside the protected tier, zip anywhere, an aad that is not text or bytes, plus every encrypt() option rule.

decryptMulti also throws the decrypt() codes for the recipient it settles on.