Signed receipts
Some operations need more than one signature — notarized approvals, multi-party contracts, M-of-N quorum operations, signed audit trails. The compact JWS has one signature slot; multi-signature JWS (RFC 7515 §7.2) has as many as you need.
This example walks through a notarized document use case — Alice issues a document, and a neutral Notary witnesses it — then generalizes to quorum approval.
The scenario
A user authorizes a bank transfer. Your system requires:
The user's signature (proves they authorized it).
Your service's signature (proves it was processed by you, not a replay).
A notary's signature (third-party attestation for audit).
All three signatures cover the same payload. Any verifier can check any subset; a court can verify all three independently.
Setup — three signers
import { generateJWK } from "unjwt/jwk";
// User's key (e.g. on their device, RSA from their YubiKey)
const userKey = await generateJWK("ES256", { kid: "alice@example.com" });
// Your service's key (persisted, long-lived)
const serviceKey = await generateJWK("Ed25519", { kid: "banktransfer-svc-2025" });
// Notary's key (third party)
const notaryKey = await generateJWK("RS256", { kid: "notary.gov/2025-q1" });
Each signer brings their own algorithm — multi-signature doesn't require everyone to agree on alg.
Signing
import { signMulti } from "unjwt/jws";
const payload = {
txId: "tx_4aX9",
from: "alice@example.com",
to: "bob@example.com",
amount: 500,
currency: "EUR",
iss: "https://banktransfer.example.com",
};
const jws = await signMulti(
payload,
[
{ key: userKey.privateKey, protectedHeader: { typ: "authorization+jwt" } },
{ key: serviceKey.privateKey, protectedHeader: { typ: "transfer+jwt" } },
{ key: notaryKey.privateKey, unprotectedHeader: { "x-role": "notary" } },
],
{ expiresIn: "30D" }, // iat and exp are shared across all signatures
);
// jws: {
// payload: "base64url(JSON)",
// signatures: [
// { protected: "...", signature: "..." }, // user
// { protected: "...", signature: "..." }, // service
// { protected: "...", header: { "x-role": "notary" }, signature: "..." },
// ]
// }
Each signature is computed independently by each signer — in a real deployment, this would typically be three separate ceremonies:
User signs their part on their device.
Sends the signed piece to your service.
Service signs their part, forwards to notary.
Notary signs and returns the complete envelope.
Multi-signature JWS supports this: you can build the structure incrementally by pushing new signatures[] entries. For this example we sign all three at once for clarity.
Policy-driven verification
Now consumers of the signed envelope need to decide what "verified" means for their use case. verifyMultiAll returns the status of every signature so the caller applies their own policy — RFC 7515 §7.2 deliberately leaves this to the application.
Policy 1 — all signatures must verify (strict)
import { verifyMultiAll } from "unjwt/jws";
const outcomes = await verifyMultiAll(jws, async (header) => {
// Lookup each signer's public key by kid
return keyStoreFor(header.kid!);
});
if (!outcomes.every((o) => o.verified)) {
const failed = outcomes.filter((o) => !o.verified);
throw new Error(
`Not all signatures valid: ${failed.map((o) => `${o.signerIndex}:${o.error.code}`).join(", ")}`,
);
}
// All three signatures verified — safe to process
Policy 2 — required signers (notary MUST sign)
const outcomes = await verifyMultiAll(jws, keyLookup);
const verifiedKids = new Set(outcomes.filter((o) => o.verified).map((o) => o.protectedHeader.kid));
if (!verifiedKids.has("notary.gov/2025-q1")) {
throw new Error("Missing notary signature — cannot audit");
}
if (!verifiedKids.has("alice@example.com")) {
throw new Error("Missing user authorization");
}
Policy 3 — M-of-N quorum
For an "any 2 of our 3 backup signers can authorize" setup:
const AUTHORIZED_KIDS = new Set([
"backup-1@example.com",
"backup-2@example.com",
"backup-3@example.com",
]);
const outcomes = await verifyMultiAll(jws, keyLookup);
const verifiedAuthorized = outcomes
.filter((o) => o.verified && AUTHORIZED_KIDS.has(o.protectedHeader.kid ?? ""))
.map((o) => o.protectedHeader.kid);
if (new Set(verifiedAuthorized).size < 2) {
throw new Error("Quorum not met (need 2 authorized signatures)");
}
Policy 4 — audit log
Even when verification fails, record every outcome for forensics:
const outcomes = await verifyMultiAll(jws, keyLookup);
for (const o of outcomes) {
await audit.log({
txId: (o.verified ? o.payload : {}).txId,
signerIndex: o.signerIndex,
signerKid: o.protectedHeader?.kid,
verified: o.verified,
error: o.verified ? null : o.error.code,
});
}
Lightweight — first valid signature
If you don't care which of the three signed, only that some authorized key signed, verifyMulti is enough:
import { verifyMulti } from "unjwt/jws";
const allPublicKeys = {
keys: [userKey.publicKey, serviceKey.publicKey, notaryKey.publicKey],
};
const { payload, signerIndex } = await verifyMulti(jws, allPublicKeys);
// Returns on first valid signature — others are not attempted
Other use cases
The same pattern scales to:
- Contract co-signing. A lease signed by tenant, landlord, and guarantor. All three must verify for the contract to be valid.
- Key rotation overlap. During a rotation window, sign with both the old and new key. Either verifies; callers choose.
- Hybrid classical + post-quantum. Sign with a classical scheme (e.g. Ed25519) and a PQC scheme. Systems that support PQC check that signature; older systems use the classical one.
- Signed audit trails. Append-only structures where each new entry is signed by a monotonically larger set of signers.
See also
- JWS multi-signature →
- JWK Sets → — for managing many signer keys.
- Authentication basics → — the single-signer case.