A webhook endpoint is a public URL that writes to your database on the strength of whatever arrives. Somebody discovers the path, posts a plausible JSON body, and your system records a payment that never happened or a signed contract nobody signed. Nothing in HTTP prevents this. Receiving a request is not evidence of anything about who sent it.
Signature verification is the control, and it is usually implemented at about seventy percent, which is the interesting part. The gap between a verification that works and a verification that looks like it works is made of four or five specific details.
What a webhook signature is
A webhook signature is a message authentication code computed by the sender over the request body using a secret only the sender and receiver know, and recomputed by the receiver to check that the bytes have not changed and the sender knew the secret. The near-universal construction is HMAC with SHA-256, sent in a header alongside a timestamp.
Two properties follow. It authenticates the sender, because producing a valid code requires the secret. It protects integrity, because any change to the body changes the code. It does not provide confidentiality, and it does not by itself prevent the same valid request being sent again, which is the third section below.
The raw body rule, and the bug it causes
The signature covers bytes. This is where most implementations go wrong, and the failure is sneaky because it is intermittent rather than total.
Most web frameworks parse a JSON body before your handler runs. If you then recompute the signature by serialising the parsed object, you are hashing a different byte sequence than the sender did, because serialisation is not canonical. Key order may differ. Whitespace disappears. Non-ASCII characters may be escaped as \uXXXX by one serialiser and emitted literally by another. The float 1.0 may come back as 1. Trailing newlines vanish.
The insidious version: it works fine for months, because your framework happens to preserve key order for small flat objects and the sender happens to use ASCII. Then a payload arrives containing a customer named Müller, or a nested object large enough to hit a different code path, and signatures start failing on a fraction of events. Someone investigating an outage at 2am finds that “signature verification is broken” and disables it to restore the flow.
The fix is structural: capture the raw body buffer before any parsing middleware touches it, verify against that buffer, and parse only after verification passes. In Express-style servers that means a raw body parser mounted specifically on the webhook route. Verify first, parse second, act third. Never re-serialise.
Constant-time comparison
Compare the computed code with the received one using a constant-time function, not ==.
A normal string comparison returns as soon as it finds a differing byte. An attacker who can send many requests and measure response time learns how many leading bytes were correct, and can therefore construct a valid signature byte by byte without ever knowing the secret. Over the public internet the timing signal is buried in network noise and the attack is impractical in many deployments. It is also a one-line fix, which makes the trade-off pointless to have: use crypto.timingSafeEqual or your language’s equivalent, and compare lengths before calling it so the function does not throw on mismatched buffers.
Replay, timestamps and idempotency
A valid signed request captured once stays valid forever unless you bound it. If an attacker obtains one legitimate payment.succeeded body with its signature, they can post it a hundred times.
Three controls stack:
A signed timestamp. The sender includes a timestamp in the signed payload, usually by signing timestamp + "." + body. Reject anything outside a tolerance window. The timestamp has to be inside the signed material, otherwise an attacker edits it freely.
A tolerance you have chosen deliberately. Five minutes is the common default. Too tight and legitimate retries from a sender with modest clock skew are rejected; too loose and the replay window is long enough to matter.
Idempotency on the effect. The real protection is that processing the same event twice does nothing the second time. Store the sender’s event identifier and check it before acting. This matters beyond security, because at-least-once delivery is the norm: senders retry on timeout, and a request that timed out after your handler committed will arrive again. Oneop’s own outbound SIEM audit stream is HMAC-SHA256 signed with at-least-once delivery for exactly this reason, so the consumer is expected to be idempotent.
Failure modes, ranked by what they cost you
| Failure | What an attacker or accident achieves | Control |
|---|---|---|
| No verification at all | Forged events write to your records from anywhere | HMAC over the raw body |
| Verification skipped when secret is unset | Missing configuration silently opens the endpoint | Fail closed on missing secret |
| Signature over re-serialised body | Intermittent false rejections, then someone disables verification | Raw buffer captured before parsing |
| Non-constant-time comparison | Byte-by-byte signature forgery given enough attempts | timingSafeEqual |
| No timestamp window | Captured requests replayable indefinitely | Signed timestamp plus tolerance |
| No idempotency | Duplicate orders, duplicate refunds, duplicate postings | Event ID deduplication |
| Single-secret rotation | Dropped events during the swap | Accept two secrets during overlap |
The failure case: the invoice that paid itself
A small ecommerce operation integrates a payment provider. The provider posts payment.succeeded to /webhooks/payments. The handler reads the order ID and amount from the body, marks the order paid, releases the stock reservation and triggers fulfilment. Verification was on the ticket and was cut when the integration ran late.
The URL appears in browser developer tools during checkout debugging, which is how it is found. An attacker places an order, opens the endpoint, and posts a body matching the shape of a real event with their own order ID. The order is marked paid. Goods ship. The provider never saw a payment, so nothing reconciles until month-end, and by then the pattern has repeated eleven times.
The instructive detail is what the reconciliation looked like afterwards. The orders were genuinely marked paid in the operational system, so support saw paid orders, fulfilment saw paid orders, and only the ledger disagreed. A forged input that writes to the system of record is not an outage; it is a set of internally consistent records that are wrong, which takes far longer to find.
What fail-closed looks like in practice
Fail closed means an event that cannot be verified is rejected, including when the reason is your own missing configuration. The dangerous convenience is the branch that skips verification when no secret is set, because it makes local development pleasant and turns one absent production secret into an open write path.
Oneop’s security page publishes the per-source behaviour as a table: Postmark and SendGrid inbound email are checked against a configured shared secret and rejected with a 503 when it is missing or wrong; DocuSign and Dropbox Sign are HMAC-SHA256 verified against a configured signing key and rejected with a 401; Stripe and Zoom events are signature-verified and rejected on failure. Signature comparisons use constant-time equality. A forged “payment succeeded” or “document signed” event does not update records, which is the only sentence in this paragraph a buyer actually needs.
The outbound direction needs its own control, and it is a different one. Anywhere a customer supplies a URL for you to call, an SSRF guard has to resolve the hostname and reject private, loopback and link-local addresses, and it has to run again on every redirect hop rather than once at configuration time, because checking only at setup leaves the DNS-rebinding window open.
Where to put this in your own build
Verification belongs in one shared function that every webhook route calls, not in each handler. Handlers that implement their own verification drift, and the one written last is the one written in a hurry. If you are building automated flows that act on inbound events, the general safety argument is in when not to automate, and the reason an inbound event should land on the same database that holds the records it changes is in why your AI agent should live inside your system of record. The list of signature-verified vendor connections Oneop ships is on the integrations page.