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 defines the General JSON Serialization:
{
"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()— produce the General serialization.verifyMulti()— return the first signature that verifies.verifyMultiAll()— return every signature's outcome for policy-driven decisions.
All three are importable from unjwt/jws (or unjwt).
signMulti
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
| Field | Role |
|---|---|
key | The signing key for this signer. key.alg is required. |
protectedHeader | Fields that are signed. Cannot set alg here (comes from key). |
unprotectedHeader | Fields not covered by the signature — metadata only. |
Errors you might see
ERR_JWS_SIGNER_ALG_INFERENCE— a signer's JWK has noalgfield. Pass a JWK withalgor generate one withgenerateJWK().ERR_JWS_B64_INCONSISTENT— signers disagree on theb64header value. RFC 7797 §3 mandates consistency.ERR_JWS_HEADER_PARAMS_NOT_DISJOINT— a parameter name appears in both the protected and unprotected header of the same signer.
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.verifyMulti
Verify until one signature passes — the "first valid signature wins" semantics:
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(): 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.
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:
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:
// 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.
payload, missing signatures[]) still throw ERR_JWS_INVALID_SERIALIZATION — there's no per-signature outcome to record in that case.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 singlesign(). - 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
verifyMultiAlland reject unless at least M of the registered signerkids are present.
Flattened form
For consumers that strictly expect Flattened serialization (single signer, JSON shape), call generalToFlattenedJWS on a single-signer General output:
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
headerfields. - The
b64: falseconstraint — all signers must agree, since they all sign the same payload bytes.
See JWE multi-recipient → for the other side.