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.

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) 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

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() 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:

// 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.

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() 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). Overlap is ERR_JWS_HEADER_PARAMS_NOT_DISJOINT.
  • crit and b64 must be protected. In unprotectedHeader they are ERR_INVALID_OPTION.
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). 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.

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.

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.

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.

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

LimitDefaultOver the limit
limits.signatures16ERR_JWS_INVALID, before any signature is read
limits.keyCandidates8ERR_JWK_KEY_NOT_FOUND, per signature

#Errors

CodeWhen
ERR_JWS_INVALIDNo signers, too many signatures, or the sign() payload rules.
ERR_JWS_SIGNER_ALG_INFERENCEA signer's alg cannot be determined.
ERR_JWS_B64_INCONSISTENTSigners disagree on b64.
ERR_JWS_HEADER_PARAMS_NOT_DISJOINTA name in both tiers of one signer.
ERR_JWS_INVALID_SERIALIZATIONNot an object, no payload or signatures, General and Flattened members mixed, or a flatten of more than one signature.
ERR_JWS_NO_MATCHING_SIGNERstrictSignerMatch found no candidate signature.
ERR_INVALID_OPTIONA signer or header that is not an object, crit or b64 unprotected, plus every sign() option rule.

verifyMulti also throws the verify() codes for the signature it settles on.