Skip to content

fix(svix): accept space-separated multi-signature headers - #3

Merged
canuto merged 1 commit into
mainfrom
fix/svix-multi-signature
Sep 20, 2026
Merged

canuto merged 1 commit into
mainfrom
fix/svix-multi-signature

Conversation

@canuto

@canuto canuto commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

The bug

Svix sends every signature it holds during a signing-secret rotation, separated by spaces:

svix-signature: v1,<old> v1,<new>

Only one matches the secret the receiver has, and the message must be accepted on that one.

svix.verify split the combined string on commas alone:

const parts = signature.split(',');
if (part === 'v1' && i + 1 < parts.length) { signatures.push(parts[i + 1].trim()); i++; }

So v1,AAA v1,BBB,t=…,id=… becomes ['v1', 'AAA v1', 'BBB', 't=…', 'id=…']:

  • the first signature is captured as the literal string AAA v1 — corrupted, never matches
  • BBB is skipped entirely, because the element before it is AAA v1, not v1

Result: every signature fails for the whole duration of a rotation. It affects svix and clerk, which delegates to it.

Reproduced before fixing:

single signature            -> true
rotation: good then other   -> false      ← a valid signature, rejected
rotation: other then good   -> false

The fix

The provider's own doc comment already documented the correct format:

 * For this library, pass signature in format: "<svix-signature>,t=<timestamp>,id=<msg-id>"
 * ... or multiple sigs "v1,<sig1> v1,<sig2>,t=...,id=..."

So the intent was right and only the parse was wrong. Split on commas and whitespace:

const parts = signature.split(/[,\s]+/);

Base64 contains no whitespace, so this cannot split a signature in half. Single-signature headers parse exactly as before.

Why it survived

Every existing svix and clerk test used a single signature — generateSvixSignature only ever produces one — so the multi-signature branch the comment describes had no coverage at all.

Added six tests:

test what it pins
matching signature first the corrupted-first-element case
matching signature last the dropped-later-elements case (fails for a different reason than the above)
matching signature among three more than one separator
none matching → false stops a parser that blindly accepts any multi-sig header from passing the three above
stale timestamp → false tolerance still applies when several signatures are present
through the headers object getSignature() is the path Clerk actually takes; the old tests passed a pre-joined string and bypassed it

Verification

  • npm test — 117 pass, 0 fail
  • Mutation-verified: restoring split(',') fails exactly the four acceptance tests and leaves the two rejection tests passing. A fix with no failing mutation would mean the tests prove nothing.

Found by

Using this package in snill to replace a hand-rolled Clerk verifier (RestDB/snill#1647 — an unsigned webhook handler that had been shadowing the verified one). Snill works around it by passing one signature at a time; once this ships it can pass the header straight through.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Mkd9oGgdMEFUftzCeiCfZw

Svix sends every signature it holds during a signing-secret rotation,
separated by spaces: `v1,<old> v1,<new>`. Only one matches the secret the
receiver has, and the message must be accepted on that one.

The parser split the combined string on commas alone, so
`v1,AAA v1,BBB,t=..,id=..` became ['v1', 'AAA v1', 'BBB', ...]: the first
signature was captured as the literal `AAA v1` and the second was dropped,
because nothing preceding it equalled 'v1'. Result: every signature fails
for the whole duration of a rotation, on svix and on clerk, which
delegates to it.

The provider's own doc comment already documented the correct format
("multiple sigs v1,<sig1> v1,<sig2>"), so the intent was right and only
the parse was wrong. Split on commas AND whitespace; base64 contains no
whitespace, so this cannot split a signature.

Every existing svix and clerk test used a single signature, which is why
this branch was never exercised. Added six: the matching signature first,
last, and among three; none matching (so a parser that blindly accepted
multi-signature headers would not pass); tolerance still applying; and one
through the headers object, which is the path Clerk actually takes.

Verified by mutation: restoring the comma-only split fails exactly the
four acceptance tests and leaves the two rejection tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mkd9oGgdMEFUftzCeiCfZw
@canuto
canuto merged commit 5015b48 into main Sep 20, 2026
1 check passed
@canuto
canuto deleted the fix/svix-multi-signature branch September 20, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant