A Signature You Compare Carefully
A payment webhook is an unauthenticated public endpoint that moves money. Ten lines decide whether that sentence is alarming or ordinary, and three separate details in them are each the kind that gets simplified away by somebody tidying up.
Somewhere in every payment integration there is a URL that anybody on the internet can post to, that has no session, no login and no CSRF protection, and that marks invoices as paid. That is not a design flaw — it is what a webhook is. What makes it safe is a short function that decides whether a given request really came from the payment provider, and it is worth reading closely, because every one of its parts looks optional.
What a signature actually proves
The provider holds a secret. You hold the same secret. When they send you an event, they compute a digest of the exact bytes of the message using that secret and put the result in a header.
You compute the same digest over the same bytes with the same secret. If your answer matches theirs, the message came from somebody who knows the secret, and it has not been altered since — because altering a byte changes the digest completely.
That is the whole idea, and it is genuinely strong. What is not strong is the space around it, which is where all three of the following live.
The cryptography is not the hard part. The hard part is not accidentally undoing it in the four lines surrounding it.
One: the raw bytes, not your parsed copy
The digest is over the exact bytes the provider sent. Not over the meaning of the message — over its literal text.
Web frameworks helpfully decode a JSON body into a structure before your code sees it. Taking that structure and encoding it back is a natural instinct and it produces a different string: key order can change, whitespace disappears, numbers may render differently, characters may be escaped differently.
Same meaning, different bytes, different digest. Every legitimate event fails verification, and the temptation at that point is enormous — the events are obviously genuine, they are coming from the right place, and the check is obviously broken. Removing the check makes everything work.
So the function takes the raw body as a string. It is a parameter rather than something read inside, which puts the requirement in the signature where a caller cannot miss it.
Two: comparing without leaking
The comparison is done with a function built for comparing secrets, not with an ordinary equality test.
An ordinary comparison stops at the first character that differs. That makes it fractionally faster when the strings differ early and slower when they share a long prefix, and that difference is measurable over enough attempts.
Which turns guessing a signature from impossible into a search. An attacker submits many attempts, measures which ones took marginally longer, and learns the digest one character at a time.
The safe version always compares every character regardless of what it finds, so the time taken carries no information. It is the same call, one word different, and it is the kind of thing that never fails a test and never appears in a screenshot.
Whether that attack is practical over a network is genuinely debatable. It costs nothing to be immune to it, which is the whole argument — the safe version is not harder to write, so declining it buys nothing at all.
Three: absence is a refusal
A request arriving with no signature at all is rejected. So is a request that arrives when no secret has been configured.
Both are the safe direction, and neither is what code naturally does. The tempting shape is to check a signature when one is present and skip when it is not — which means an attacker simply omits the header. And the other tempting shape is to skip verification when no secret is configured, so that a half-set-up workspace still works, which turns an unfinished setup into an open endpoint.
And a rule about where checks may fail open
This connector sits next to another where the answer is the opposite, and the pair only makes sense together.
| A provider that signs | A provider that does not | |
|---|---|---|
| What authenticates the event | The signature | The source address, and nothing else |
| Address filtering is | A second lock | The only lock |
| With no address list set | Everything is accepted | Nothing is accepted |
| Why | The signature already decided | Clearing a setting must not disarm it |
It reads as inconsistent and it is one rule: a check may fail open only where something else authenticates. Where it is the sole check on a public endpoint that moves money, an empty configuration has to mean refuse rather than allow.
The failure that rule prevents is specific and quiet. Somebody clears an address list intending to switch off a filter they think is redundant, and switches off the only authentication there was, and nothing changes visibly because genuine events keep arriving.
The check that does not depend on any of this
One more layer, and it is the one that makes the rest survivable.
A payment can be verified directly. Take the reference, ask the provider what happened to it, and get an authoritative answer over a connection you opened, authenticated by your own credentials.
That path depends on no webhook, no signature and no address. It is the answer to did this actually get paid independent of anything that arrived unsolicited.
Which is the right shape for money. A webhook is fast and convenient and is somebody else's promise; a verification you initiate is slower and is a fact. The event tells you to go and look, and looking is what settles it.
What is in place, layer by layer
How a payment event is trusted
A digest over the raw body
The exact bytes received, passed in as a string rather than re-encoded from a parsed structure, because re-encoding changes the bytes and fails every genuine event.
A timing-safe comparison
The digests are compared with a function that always examines every character, so the time taken reveals nothing about how close a guess was.
A missing signature refused
An event arriving without one is rejected rather than skipped, so the check cannot be bypassed by omitting the header.
A missing secret refused
No configured secret means no verification is possible, which is treated as a refusal rather than as permission to proceed.
Address filtering as a second lock here
Because the signature already authenticates, source filtering is defence in depth and an empty list is not a security change.
And the only lock elsewhere, failing closed
On the provider that signs nothing, the address check is the whole authentication and an empty list refuses everything.
An independent verification path
A payment can be confirmed by asking the provider directly over a connection we opened, which depends on no webhook at all.
Separate secrets per workspace
Each workspace verifies against its own provider secret, stored encrypted, so one workspace's events cannot be signed with another's key.
Transfers verified the same way
Outbound payment events carry their own signature check rather than inheriting trust from the collection path.
Every part of this is the kind that looks removable. The raw-body requirement reads as awkwardness, the timing-safe comparison as a longer way to write equality, and failing closed as unhelpfulness — which is why each is worth knowing by name.
Three positions held on purpose
- Absence is a refusal in both directions. A missing signature and a missing secret both fail the check, because the shapes that look more helpful — skip when there is nothing to check — are exactly the ones an attacker uses.
- A check may fail open only where something else authenticates. That single rule produces opposite behaviour on two providers, and it is the rule rather than the behaviour that has to be consistent.
- A webhook is a prompt rather than a proof. Money is settled by a verification we initiate against our own credentials, so the fast convenient path is never the only thing standing behind a record that says paid.
Five questions about payment webhooks
What is the signature computed over?
A good answer sounds like
The raw body.
What ours actually is
The exact bytes received, never a re-encoding of a parsed structure.
How are the digests compared?
A good answer sounds like
In constant time.
What ours actually is
With a comparison that examines every character, so timing reveals nothing.
What if the signature header is missing?
A good answer sounds like
Rejected.
What ours actually is
Absence is a refusal, not a reason to skip the check.
Does clearing the IP list weaken anything?
A good answer sounds like
Depends on the provider.
What ours actually is
Here the signature still authenticates. On the provider that signs nothing, an empty list refuses everything.
Do you trust the webhook alone?
A good answer sounds like
No.
What ours actually is
A payment can be verified directly against the provider over a connection we opened.
Our take
Webhook verification is a good place to judge how a system was built, because every part of doing it properly looks like something you could simplify. Requiring the raw body reads as an awkward interface. Using a special comparison function reads as a longer way to write equality. Refusing a request with no signature reads as unhelpful when the events are obviously genuine. Each of those survives only if somebody knows what it is for, and the failure when one is removed is not a broken feature — it is an endpoint that still works perfectly and now accepts events from anybody. The correctness is invisible in both directions, which is exactly why it is worth naming out loud.
Ask what happens when the signature check is removed
The honest answer for most payment endpoints is that everything keeps working, which is precisely what makes the check worth understanding rather than merely having.
Talk through payment collectionFrequently asked questions
What stops somebody sending fake payment confirmations?
The signature. Each event carries a digest computed over its exact bytes using a secret shared with the provider, and it is recomputed and compared before anything is acted on. A request with no signature, or one arriving when no secret is configured, is refused rather than allowed through.
Why does one payment provider fail closed on IP filtering and another fail open?
Because one signs its events and the other does not. Where a signature already authenticates, address filtering is a second lock and an empty list is not a security change. Where nothing else authenticates, the address check is the only lock and clearing it must refuse everything rather than accept everything.
Is a payment marked as received purely on a webhook?
A payment can be confirmed directly by asking the provider about the reference over a connection we open with our own credentials. That path depends on no webhook, no signature and no source address, and it is what settles the question when it matters.
Could a webhook from one workspace affect another?
Each workspace verifies against its own provider secret, so an event signed with one workspace's key does not validate against another's. The secrets are stored encrypted and never appear in a log line.
What happens if a webhook is delivered twice?
Duplicate delivery is a normal part of how webhooks work — providers retry when they are unsure a delivery landed. Payments are keyed on their own reference, so a repeat event resolves to the same record rather than creating a second one.