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: 1One 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:
| Option | Type | Default | What it does |
|---|---|---|---|
sharedUnprotectedHeader | Record<string, unknown> | none | Members for every recipient, written as unprotected. Not integrity protected. |
aad | string | Uint8Array | none | Extra 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:
| Tier | Holds | Integrity protected |
|---|---|---|
protected | enc, typ, your protectedHeader | yes |
unprotected | sharedUnprotectedHeader | no |
recipients[].header | alg, kid, epk, p2s, p2c, iv, tag and your header | no |
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
keyis a JWK with analg. A JWK without one, or aCryptoKey, isERR_JWE_RECIPIENT_ALG_INFERENCE. dirand bareECDH-ESare refused withERR_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 asdir. UseECDH-ES+A*KWinstead, orencrypt()for one recipient.- A PBES2 recipient with no
p2cis 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" },
// }protectedHeaderis only what was protected.encryptMultiwritesalgper recipient, so read it fromrecipientHeader.encis 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, disallowedalg, 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.typis checked against the protected header only.limits.pbes2Iterationsis 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
| Code | When |
|---|---|
ERR_JWE_INVALID | No recipients, more than limits.recipients, or the decrypt() token checks. |
ERR_JWE_RECIPIENT_ALG_INFERENCE | A recipient key that is not a JWK or has no alg. |
ERR_JWE_ALG_FORBIDDEN_IN_MULTI | A recipient resolves to dir or bare ECDH-ES. |
ERR_JWE_HEADER_PARAMS_NOT_DISJOINT | A name in more than one header tier. |
ERR_JWE_INVALID_SERIALIZATION | Not an object, recipients mixed with top-level header or encrypted_key, or a flatten of more than one recipient. |
ERR_JWE_NO_MATCHING_RECIPIENT | strictRecipientMatch found no candidate recipient. |
ERR_INVALID_OPTION | crit 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.