# Multi-signature

> 

Sometimes one signature isn't enough. Notarized documents, quorum-approved operations, multi-algorithm key-rotation overlap, cross-organization attestations — they all need **multiple signatures on the same payload**.

The compact `sign()`/`verify()` path only covers one signer. For the rest, [RFC 7515 §7.2](https://www.rfc-editor.org/rfc/rfc7515#section-7.2) defines the **General JSON Serialization**:

```json
{
  "payload": "<base64url of the shared payload>",
  "signatures": [
    { "protected": "<base64url>", "header": {...}, "signature": "..." },
    { "protected": "<base64url>", "header": {...}, "signature": "..." }
  ]
}
```

One payload, many signatures — each signer has their own protected header (with its own `alg`, `kid`, etc.).

unjwt exposes three functions for this:

- [`signMulti()`](#signmulti) — produce the General serialization.
- [`verifyMulti()`](#verifymulti) — return the **first** signature that verifies.
- [`verifyMultiAll()`](#verifymultiall) — return **every** signature's outcome for policy-driven decisions.

All three are importable from `unjwt/jws` (or `unjwt`).

## `signMulti`

```ts [sign-multi.ts]
import { signMulti } from "unjwt/jws";

const jws = await signMulti(
  { sub: "u1", role: "admin" },
  [
    { key: aliceRsaPrivateJwk }, // alg inferred from JWK → RS256
    { key: bobEdPrivateJwk, protectedHeader: { typ: "vc+jwt" } }, // alg from JWK → Ed25519
    { key: witnessHmacJwk, unprotectedHeader: { "x-role": "witness" } },
  ],
  { expiresIn: "1h" },
);

// jws.payload          — base64url of JSON-serialized payload
// jws.signatures       — array of { protected, header?, signature }
```

Each signer is independent: they sign `BASE64URL(ownProtectedHeader).BASE64URL(payload)` with their own key. The claims (`iat`, `exp`, `jti`) are computed once for the shared payload, not per signer.

### Per-signer fields

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

<tbody>
  <tr>
    <td>
      <code>
        key
      </code>
    </td>
    
    <td>
      The signing key for this signer. <strong>
        <code>
          key.alg
        </code>
        
         is required.
      </strong>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        protectedHeader
      </code>
    </td>
    
    <td>
      Fields that are signed. Cannot set <code>
        alg
      </code>
      
       here (comes from key).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        unprotectedHeader
      </code>
    </td>
    
    <td>
      Fields not covered by the signature — metadata only.
    </td>
  </tr>
</tbody>
</table>

### Errors you might see

- `ERR_JWS_SIGNER_ALG_INFERENCE` — a signer's JWK has no `alg` field. Pass a JWK with `alg` or generate one with `generateJWK()`.
- `ERR_JWS_B64_INCONSISTENT` — signers disagree on the `b64` header value. [RFC 7797 §3](https://www.rfc-editor.org/rfc/rfc7797#section-3) mandates consistency.
- `ERR_JWS_HEADER_PARAMS_NOT_DISJOINT` — a parameter name appears in both the protected and unprotected header of the same signer.

<note>

`signMulti` always emits the **General** serialization, even for a single signer. If you need the shorter **Flattened** form (one signer, JSON shape), post-process with [`generalToFlattenedJWS`](#generaltoflattenedjws).

</note>

## `verifyMulti`

Verify until **one** signature passes — the "first valid signature wins" semantics:

```ts [verify-multi-first.ts]
import { verifyMulti } from "unjwt/jws";

const { payload, signerIndex, signerHeader } = await verifyMulti(jws, alicePublicJwk);
// signerIndex: number — which entry of jws.signatures verified
```

`verifyMulti()` accepts **parsed General or Flattened** serializations (not raw strings — parse the JSON yourself). It's the right fit when you don't care which key signed, only that some trusted key did.

### Key input shapes

Same as [`verify()`](/jwt/jws/verifying): a `JWK`, a `JWKSet`, a `CryptoKey`, raw bytes, or a `JWKLookupFunction`. When you pass a set or a lookup, unjwt retries per signature until one verifies.

### Strict signer matching

With `strictSignerMatch: true`, a signature is only attempted if its header unambiguously matches the provided key (by `kid`, then by `kty`/`crv`/length). Mismatched signatures are **skipped** rather than attempted — if none match, `ERR_JWS_NO_MATCHING_SIGNER` is thrown.

```ts
await verifyMulti(jws, alicePublicJwk, { strictSignerMatch: true });
```

Useful for audit paths where you want "is *this specific key's* signature valid?" rather than "is any signature valid?".

## `verifyMultiAll`

Return the status of **every** signature independently — the library never throws on a per-signature failure, so the caller can apply any policy:

```ts [verify-multi-all.ts]
import { verifyMultiAll } from "unjwt/jws";

const outcomes = await verifyMultiAll(jws, async (header) => myKeyStore.get(header.kid!));

// All-must-verify policy
if (!outcomes.every((o) => o.verified)) {
  throw new Error("not all signatures valid");
}

// Quorum policy: M of N distinct signers
const verifiedKids = new Set(outcomes.filter((o) => o.verified).map((o) => o.protectedHeader.kid));
if (verifiedKids.size < 2) throw new Error("quorum not met");

// Specific-signer policy
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 required signers");
}

// Audit log — record every outcome regardless of policy
for (const o of outcomes) {
  log(o.signerIndex, o.verified ? "ok" : o.error.code);
}
```

Each outcome is one of:

```ts
// Success
{ signerIndex: number, verified: true, payload: T, protectedHeader: ..., signerHeader?: ... }

// Failure (captured — does NOT throw)
{ signerIndex: number, verified: false, error: JWTError, protectedHeader?: ..., signerHeader?: ... }
```

Failures captured include: malformed headers, disallowed `alg`, `typ` mismatch, resolver errors, bad signatures, critical-header violations, JWT-claim failures.

<warning>

Structural errors on the **envelope itself** (non-object input, missing `payload`, missing `signatures[]`) still throw `ERR_JWS_INVALID_SERIALIZATION` — there's no per-signature outcome to record in that case.

</warning>

### The key resolver is required

Unlike `verify` and `verifyMulti`, `verifyMultiAll` always takes a **function**. Signatures typically come from different signers with different keys; a function is the natural way to express that. Wrap a static set as `(header) => myJwkSet` if that's what you have.

## Use cases

- **Key rotation overlap.** During a rotation window, sign with both the old and new key; verifiers accept either via `verifyMulti`. Once the old key is retired, switch back to single `sign()`.
- **Algorithm agility.** Sign with an HMAC key (fast, cheap to verify for your own backend) and an EdDSA key (publicly verifiable via JWKS). Each consumer uses the signature they trust.
- **Notary / witness.** Attach a secondary signature from a neutral party ("alice signed, notary witnessed") without bundling their keys.
- **Hybrid classical + post-quantum signatures.** Sign with two algorithms so a migration doesn't break old tokens.
- **M-of-N approval.** Use `verifyMultiAll` and reject unless at least M of the registered signer `kid`s are present.

## Flattened form

For consumers that strictly expect **Flattened** serialization (single signer, JSON shape), call `generalToFlattenedJWS` on a single-signer General output:

```ts [flattened.ts]
import { signMulti, generalToFlattenedJWS } from "unjwt/jws";

const general = await signMulti(payload, [singleSigner], opts);
const flattened = generalToFlattenedJWS(general);
// { payload, protected, header?, signature }
```

Throws `ERR_JWS_INVALID_SERIALIZATION` if the input has zero or multiple signatures — Flattened is strictly single-signer.

`verifyMulti` and `verifyMultiAll` both accept Flattened input directly — they auto-normalize to General in memory.

## How JWS multi-sig differs from JWE multi-recipient

They look similar on the surface, but:

- **Payload is shared, headers are per-signer.** Opposite of JWE where the protected header is shared and per-recipient state lives in `recipients[]`.
- **No shared unprotected header at the top level** — only per-signature `header` fields.
- **The b64: false constraint** — all signers must agree, since they all sign the same payload bytes.

See [JWE multi-recipient →](/jwt/jwe/multi-recipient) for the other side.
