Support

Need help?

Most answers are already written down. For everything else, Oneop does not publish a response time — there is no SLA instrument behind one — so the fastest thing you can do is send a complete first message.

Search the help centreEmail support@oneop.ioOpen your workspace

Worth including

  • The email you log in with
  • The URL of the screen it happened on
  • What you expected, and what happened instead

Goes somewhere else

Security

Webhook signature verification: an HTTP request is not enough

By The Oneop team8 min read

TL;DR

A webhook endpoint that does not verify a signature is an unauthenticated write path into your business records. Verification means computing an HMAC over the exact raw request body, comparing it in constant time, enforcing a timestamp window against replay, and failing closed when the signing secret is absent.

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

FailureWhat an attacker or accident achievesControl
No verification at allForged events write to your records from anywhereHMAC over the raw body
Verification skipped when secret is unsetMissing configuration silently opens the endpointFail closed on missing secret
Signature over re-serialised bodyIntermittent false rejections, then someone disables verificationRaw buffer captured before parsing
Non-constant-time comparisonByte-by-byte signature forgery given enough attemptstimingSafeEqual
No timestamp windowCaptured requests replayable indefinitelySigned timestamp plus tolerance
No idempotencyDuplicate orders, duplicate refunds, duplicate postingsEvent ID deduplication
Single-secret rotationDropped events during the swapAccept 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.

Frequently asked questions

Why must a webhook signature be computed over the raw body?

Because the signature covers exact bytes, not meaning. Parsing JSON and re-serialising it changes key order, whitespace, unicode escaping and number formatting, so the bytes you hash are no longer the bytes the sender hashed. The signature then fails on legitimate requests, and the usual reaction is to disable verification, which is worse than never having added it.

Is comparing signatures with == actually exploitable?

Treat it as exploitable and move on. A short-circuiting comparison returns faster on an earlier mismatch, which leaks a byte-at-a-time oracle to an attacker who can send many requests and measure. Over a network the signal is noisy and the attack is impractical in many settings, but the fix is one function call, so there is no version of this trade-off worth having.

What timestamp tolerance should a webhook endpoint allow?

Small enough to bound replay, large enough to survive clock skew and sender retries. Five minutes is the common default. The tolerance must be enforced on a timestamp that is itself inside the signed payload, otherwise an attacker simply edits it. Pair the window with idempotency so that in-window duplicates are harmless.

How do you rotate a webhook signing secret without downtime?

Accept two secrets during an overlap window. Add the new secret as an additional accepted key, verify against either, ask the sender to switch, confirm from logs that traffic is arriving on the new key, then remove the old one. Rotating by replacing the value in one step drops every event in flight.

What should happen if the signing secret is not configured?

The endpoint should reject the request. The tempting behaviour is to skip verification when no secret is present, so that development is convenient, and that default turns one missing secret in production into an open write endpoint. Fail closed: no secret, no processing.

Does HTTPS make webhook signatures unnecessary?

No. TLS proves you are talking to the host named in the certificate and protects the data in transit. It says nothing about who originated a request arriving at your public endpoint, because anyone on the internet can open a TLS connection to it and POST whatever they like. The signature is the authentication; TLS is the channel.

Written by

The Oneop team

Writing from the team that builds Oneop — an AI customer platform with web chat and email on top of a real CRM, help desk, ledger and warehouse.

Related reading

Start on the free plan and see the real thing

No credit card. Free plan, 2 seats.

Free forever · 2 seats · No credit card