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); // trueA 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.
algcomes from the signer'salg, else the JWK'salg, else theCryptoKey's algorithm. An alg-less JWK isERR_JWS_SIGNER_ALG_INFERENCE.kidis copied from a JWK only. Put it inprotectedHeaderfor aCryptoKey.- Disjoint tiers. A name may sit in one tier only (RFC 7515 §7.2.1). Overlap is
ERR_JWS_HEADER_PARAMS_NOT_DISJOINT. critandb64must be protected. InunprotectedHeaderthey areERR_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" },
// }protectedHeaderis only what was signed. A signer may putalgin the unprotected tier, where it still passes the allow-list. A Flattened object withoutprotectedyields{}.- 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.typis matched against the protected header only. Atypin the unprotected tier never satisfies it.kidis 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
| 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() codes for the signature it settles on.