
# Multi-signature

> Sign one payload with several keys in a JWS JSON object, then accept the first valid signature or check every one of them.

```ts
import { signMulti, verifyMulti, verifyMultiAll } from "unjwt/jws";
import { generateJWK } from "unjwt/jwk";

const alice = await generateJWK("ES256", { kid: "alice" });
const notary = await generateJWK("Ed25519", { kid: "notary" });

const jws = await signMulti(
  { doc: "contract-42", amount: 1200 },
  [{ key: alice.privateKey }, { key: notary.privateKey, unprotectedHeader: { role: "notary" } }],
  { expiresIn: "1h" },
);

const { signerIndex } = await verifyMulti(jws, notary.publicKey); // 1
const outcomes = await verifyMultiAll(jws, () => ({ keys: [alice.publicKey, notary.publicKey] }));
outcomes.every((o) => o.verified); // true
```

A compact token holds exactly one signature. The JSON serialization ([RFC 7515 §7.2](https://www.rfc-editor.org/rfc/rfc7515#section-7.2)) holds one payload and a list of signatures, each with its own header and algorithm. Use it for co-signed documents, quorum approvals, or overlapping old and new keys during a rotation.

## Signatures

```ts
function signMulti(
  payload: string | Uint8Array | Record<string, unknown>,
  signers: JWSMultiSigner[],
  options?: { expiresIn?; expiresAt?; notBeforeIn?; notBeforeAt?; currentDate? },
): Promise<JWSGeneralSerialization>;

interface JWSMultiSigner {
  key: JWSSignJWK | CryptoKey;
  alg?: JWSAlgorithm; // from the JWK's alg, or the CryptoKey's algorithm
  protectedHeader?: JWSHeaderParameters; // signed; `alg` cannot be set here
  unprotectedHeader?: JWSHeaderParameters; // not signed
}

function verifyMulti<T = JWTClaims>(
  jws: JWSGeneralSerialization | JWSFlattenedSerialization,
  key: JWSVerifyJWK | JWKSet | CryptoKey | Uint8Array | JWSKeyLookup,
  options?: JWSVerifyOptions & {
    strictSignerMatch?: boolean;
    limits?: { signatures?: number; keyCandidates?: number };
  },
): Promise<{
  payload: T;
  protectedHeader: JWSHeaderParameters;
  signerHeader?: JWSHeaderParameters;
  signerIndex: number;
}>;

function verifyMultiAll<T = JWTClaims>(
  jws: JWSGeneralSerialization | JWSFlattenedSerialization,
  keyResolver: JWSKeyLookup,
  options?: JWSVerifyOptions & { limits?: { signatures?: number; keyCandidates?: number } },
): Promise<JWSMultiVerifyOutcome<T>[]>;

function generalToFlattenedJWS(jws: JWSGeneralSerialization): JWSFlattenedSerialization;
```

`verifyMulti` and `verifyMultiAll` take every [`verify()`](/jwt/jws/verify) option, with the same defaults and the same `payload` modes. Pass the parsed object: neither accepts a string. Compact tokens go through `verify`.

## General and Flattened

`signMulti` always returns the General form, even for one signer, so the shape stays stable when you add signers later:

```ts
// jws
{
  "payload": "eyJkb2MiOiJj…",
  "signatures": [
    { "protected": "eyJraWQiOiJh…", "signature": "1AxP5Fqr…" },
    { "protected": "eyJraWQiOiJu…", "signature": "ZVa-oWa5…", "header": { "role": "notary" } }
  ]
}
// protected[0]: {"kid":"alice","alg":"ES256","typ":"JWT"}
// protected[1]: {"kid":"notary","alg":"Ed25519","typ":"JWT"}
```

The Flattened form is the same with one signature lifted to the top level. `generalToFlattenedJWS` converts a one-signer General object and throws `ERR_JWS_INVALID_SERIALIZATION` for any other count. Both verifiers accept either form.

```ts
const flat = generalToFlattenedJWS(await signMulti({ sub: "u" }, [{ key: alice.privateKey }]));
Object.keys(flat); // ["payload", "signature", "protected"]

generalToFlattenedJWS(jws);
// JWTError ERR_JWS_INVALID_SERIALIZATION: Flattened JWS Serialization requires exactly one signature, got 2.
```

## Per-signer headers

Each signer has two header tiers. `protectedHeader` is signed. `unprotectedHeader` travels in the clear and anyone can change it. `signMulti` builds each protected header the way [`sign()`](/jwt/jws/sign) does: the JWK's `kid`, your members, then `alg`, with `typ: "JWT"` for an object payload.

- **`alg`** comes from the signer's `alg`, else the JWK's `alg`, else the `CryptoKey`'s algorithm. An alg-less JWK is `ERR_JWS_SIGNER_ALG_INFERENCE`.
- **`kid`** is copied from a JWK only. Put it in `protectedHeader` for a `CryptoKey`.
- **Disjoint tiers.** A name may sit in one tier only ([RFC 7515 §7.2.1](https://www.rfc-editor.org/rfc/rfc7515#section-7.2.1)). Overlap is `ERR_JWS_HEADER_PARAMS_NOT_DISJOINT`.
- **`crit` and `b64`** must be protected. In `unprotectedHeader` they are `ERR_INVALID_OPTION`.

```ts
await signMulti({ sub: "u" }, [{ key: alice.privateKey, unprotectedHeader: { kid: "x" } }]);
// JWTError ERR_JWS_HEADER_PARAMS_NOT_DISJOINT: JWS header parameter "kid" appears in both protected and unprotected headers of a signature; they must be disjoint (RFC 7515 §7.2.1).
```

### `b64` across signers

All signers share one payload segment, so they must agree on `b64` ([RFC 7797 §3](https://www.rfc-editor.org/rfc/rfc7797#section-3)). A signer that sets no `b64` follows the others. Disagreement is `ERR_JWS_B64_INCONSISTENT`. With `b64: false` every protected header gets `"b64"` in its `crit`, and a payload with `.` is fine here because the JSON form does not split on dots.

```ts
const raw = await signMulti("a.b", [
  { key: alice.privateKey, protectedHeader: { b64: false } },
  { key: notary.privateKey, protectedHeader: { b64: false } },
]);
raw.payload; // "a.b"
(await verifyMulti(raw, notary.publicKey, { payload: "string" })).payload; // "a.b"
```

## verifyMulti: first valid signature

`verifyMulti` walks the signatures in order and returns the first that verifies under your key, with all `verify` checks applied.

```ts
const result = await verifyMulti(jws, notary.publicKey);
// {
//   payload: { doc: "contract-42", amount: 1200, iat: 1792068538, exp: 1792072138 },
//   protectedHeader: { kid: "notary", alg: "Ed25519", typ: "JWT" },
//   signerIndex: 1,
//   signerHeader: { role: "notary" },
// }
```

- `protectedHeader` is only what was signed. A signer may put `alg` in the unprotected tier, where it still passes the allow-list. A Flattened object without `protected` yields `{}`.
- A signature-level failure (bad header, disallowed `alg`, unusable key, wrong signature) moves on to the next signature, so a junk entry placed first cannot hide a valid one. If none verifies, the last failure is thrown.
- Once a signature verifies, a `crit`, payload or claim failure is thrown at once.
- `options.typ` is matched against the protected header only. A `typ` in the unprotected tier never satisfies it.
- `kid` is read from both tiers, so the key lookup can be steered by unsigned data. The signature must still verify under whatever key comes back.

With `strictSignerMatch: true`, a signature is tried only when the key could have made it (matching key type, curve or size; a `kid` on the key is a hint and never rules it out). Nothing matching is `ERR_JWS_NO_MATCHING_SIGNER` without any crypto.

```ts
const bob = await generateJWK("ES256", { kid: "bob" });
await verifyMulti(jws, bob.publicKey, { strictSignerMatch: true });
// JWTError ERR_JWS_NO_MATCHING_SIGNER: No signature matched the provided key under strictSignerMatch.
```

## verifyMultiAll: every signature

`verifyMultiAll` checks each signature on its own and returns one outcome per signature. It throws only for an envelope it cannot read (`ERR_JWS_INVALID_SERIALIZATION`) or one over `limits.signatures`. Everything else becomes `verified: false` with the `error`, so your code applies the policy: all must pass, M of N, or specific signers.

```ts
const outcomes = await verifyMultiAll(jws, () => ({ keys: [alice.publicKey] }));
outcomes.map((o) => (o.verified ? `${o.signerIndex} ok` : `${o.signerIndex} ${o.error.code}`));
// ["0 ok", "1 ERR_JWS_ALG_NOT_ALLOWED"]

const signedBy = new Set(outcomes.filter((o) => o.verified).map((o) => o.protectedHeader.kid));
if (!signedBy.has("alice") || !signedBy.has("notary")) throw new Error("missing a required signer");
```

The key argument must be a lookup function, because each signature usually needs a different key. Wrap a static set as `() => jwks`. Count signers by verified `kid` and never by array length, since anyone can append a signature entry.

## Limits

| Limit                  | Default | Over the limit                                  |
| ---------------------- | ------- | ----------------------------------------------- |
| `limits.signatures`    | `16`    | `ERR_JWS_INVALID`, before any signature is read |
| `limits.keyCandidates` | `8`     | `ERR_JWK_KEY_NOT_FOUND`, per signature          |

## Errors

| Code                                 | When                                                                                                                       |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `ERR_JWS_INVALID`                    | No signers, too many signatures, or the `sign()` payload rules.                                                            |
| `ERR_JWS_SIGNER_ALG_INFERENCE`       | A signer's `alg` cannot be determined.                                                                                     |
| `ERR_JWS_B64_INCONSISTENT`           | Signers disagree on `b64`.                                                                                                 |
| `ERR_JWS_HEADER_PARAMS_NOT_DISJOINT` | A name in both tiers of one signer.                                                                                        |
| `ERR_JWS_INVALID_SERIALIZATION`      | Not an object, no `payload` or `signatures`, General and Flattened members mixed, or a flatten of more than one signature. |
| `ERR_JWS_NO_MATCHING_SIGNER`         | `strictSignerMatch` found no candidate signature.                                                                          |
| `ERR_INVALID_OPTION`                 | A signer or header that is not an object, `crit` or `b64` unprotected, plus every `sign()` option rule.                    |

`verifyMulti` also throws the [`verify()`](/jwt/jws/verify#errors) codes for the signature it settles on.
