Designing Webhook Receivers: How to Verify Signatures Correctly on the Receiving Side
Webhook signature verification is the boundary that determines whether your integration is a security feature or a security hole. Most webhook receivers we see have subtle verification bugs that pass casual review but fail under adversarial conditions.
The signature verification step in a webhook receiver is one of the most consequential pieces of integration code most developers write. Done correctly, it provides strong authentication of payload origin and integrity at modest cost. Done incorrectly, it provides a false sense of security while leaving the integration vulnerable to forgery, replay, and trivial bypass attacks. The verification logic is small enough to fit on a single screen, which is part of why subtle bugs are common: there is not enough code for the bugs to feel suspicious.
What signature verification proves
HMAC-based webhook signatures prove two things: that the payload was produced by an entity that knows the shared signing secret, and that the payload has not been modified in transit. They do not prove freshness (the payload could be a replay of an old legitimate webhook), they do not prove uniqueness (the same payload could be delivered multiple times legitimately), and they do not prove anything about the customer-side state at the time of payload creation. The verification step is necessary but not sufficient for trustworthy webhook handling.
The strength of the proof depends on the secret remaining secret. If the signing secret is exposed (in logs, in source control, in error messages, in client-side code), the signature provides no authentication at all because anyone who knows the secret can construct a valid signature. The secret management practices around webhook signing keys should match the practices around API keys.
The verification algorithm
The standard HMAC-SHA256 verification has four steps. First, extract the signature and timestamp from the request headers. Second, recompute the HMAC over the canonical payload (typically timestamp + "." + raw_body or similar provider-specific format) using the signing secret. Third, compare the computed signature to the received signature using a timing-safe comparison. Fourth, check that the timestamp is within an acceptable window of the current time (typically 5 minutes).
Each of these steps has at least one common bug. We will cover them in order.
Bug 1: parsing the body twice
The signature is computed over the raw bytes of the request body. If the receiver parses the body as JSON, then re-serializes it for signature checking, the bytes will probably differ because JSON serialization is not canonical. Field ordering, whitespace, number formatting, and Unicode escaping all vary between implementations.
The fix is to access the raw body before any JSON parsing happens. Most web frameworks provide a way to access the raw body bytes. In FastAPI it is await request.body(). In Express it is configuring the body-parser to retain raw bytes via a verify function. In Rails it is request.raw_post. The discipline is to do signature verification on the raw bytes before any framework-level body parsing happens, and only parse the body after verification has passed.
A subtle variant of this bug appears with frameworks that decompress request bodies automatically. If the sender uses gzip compression and the receiver framework decompresses transparently, the body bytes the verification sees may be different from what the sender signed. The mitigation is to disable automatic decompression for webhook endpoints, or to verify signatures on the compressed bytes if the sender signs compressed bodies.
Bug 2: timing-unsafe comparison
Comparing two byte strings with the standard equality operator is timing-unsafe. The comparison short-circuits on the first mismatched byte, which means the time taken to compare correlates with how many bytes matched. An attacker who can measure response times can use this to recover the correct signature one byte at a time, even though the attacker does not know the secret.
The fix is to use a timing-safe comparison function. Most languages have one in the standard library: hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js, subtle.ConstantTimeCompare in Go's crypto/subtle package, ActiveSupport::SecurityUtils.secure_compare in Rails. The function does the comparison in constant time regardless of how many bytes match, which prevents the timing-side-channel attack.
The practical exploitability of timing attacks over the internet is debated; the network jitter is typically larger than the per-byte timing difference. But the cost of using the timing-safe comparison is zero (it is a one-line change) and the failure mode if exploitable is total bypass of signature verification, so the right default is always to use the timing-safe comparison.
Bug 3: timestamp window too large or absent
Without timestamp verification, a valid signature is valid forever. An attacker who intercepts one legitimate webhook payload can replay it indefinitely. The mitigation is to include a timestamp in the signed payload and reject signatures whose timestamp is outside an acceptable window.
The window choice trades off security against clock skew tolerance. Five minutes is the canonical default that Stripe, Linear, and others use. It is large enough to tolerate reasonable clock skew between sender and receiver, small enough that the replay window is bounded. Tighter windows (one minute) provide stronger replay protection but break for clients with significant clock skew. Wider windows (one hour) provide weak replay protection.
The bug to avoid is having no timestamp window at all, which is the default behavior if you simply check whether the signature is valid without also checking when the signature was created. The fix is to make the timestamp check explicit and to fail loudly when the timestamp is outside the window.
Bug 4: idempotency not enforced
Even with timestamp verification, the same payload can be legitimately delivered multiple times (sender retries on receiver 5xx response, for example). The receiver needs to track which event IDs have been processed and skip duplicates. The standard pattern is a processed_events table with the event ID as the primary key and an INSERT-on-conflict-do-nothing pattern as the deduplication primitive.
The bug to avoid is to rely on the database state changes that the webhook processing produces as implicit deduplication. This fails for any webhook that does not modify database state, for any webhook that modifies state idempotently (an UPDATE that sets the same value is silently fine but the side effects like sending an email are not), and for any webhook whose state changes can be observed before they are committed.
The discipline is to make the deduplication explicit: insert into processed_events first, in the same transaction as the side effect commit. If the insert fails because the event ID already exists, skip the side effect.
Bug 5: not validating the schema
The signature proves the payload was sent by the trusted sender. It does not prove the payload conforms to the expected schema. If the sender changes the schema (or a new event type is introduced that you do not handle), the receiver may process garbage values.
The mitigation is explicit schema validation after signature verification and before any business logic. Pydantic models in Python, zod schemas in Node.js, JSON Schema validation, or struct unmarshaling with required fields in Go are all reasonable. The validation should reject payloads with unknown event types rather than processing them with default values.
The schema validation also catches the case where the sender's API changes in backward-incompatible ways. The verification step would still pass (the new payload is signed correctly) but the schema validation would catch the mismatch and prevent the receiver from acting on payloads it does not understand.
Bug 6: ack before processing
The receiver should acknowledge webhook receipt (return 2xx) only after the side effects have committed to durable storage. If the receiver acknowledges before processing and then crashes, the side effect is lost and the sender will not retry because it received a successful ack.
The mitigation is to queue the webhook payload synchronously (write to a database table or message queue with durable storage) before returning the 2xx response, then process the queued payload asynchronously. This way the ack confirms only that the webhook payload was received, not that it was fully processed. The async processing handles retries internally.
The alternative is to process synchronously and ack only after the side effects commit, but this works only for short-running side effects. Long-running processing (more than a few seconds) will time out at network boundaries and will not be reliable.
Bug 7: secret rotation not handled
Most webhook providers support multiple active signing secrets to enable rotation without an outage. The receiver should accept either secret during the rotation window. If the receiver only supports one secret at a time, rotation requires coordinated cutover that is fragile in practice.
The implementation is straightforward: keep a list of active secrets, try verification with each, accept if any one succeeds. The performance cost is negligible because HMAC verification is fast and the number of active secrets during rotation is typically two or three at most.
The rotation discipline complements this: when a new secret is created, add it to the receiver's active list first, then update the sender configuration to use the new secret, then remove the old secret from the receiver's active list after the transition window. The dashboard surface that supports this should be planned with the rotation pattern in mind.
What the verification should not do
The verification should not log the signing secret, the computed signature, the received signature, or the raw payload at any log level above DEBUG. The signing secret in particular should never appear in logs at all, because logs are typically retained longer than secrets and routed to systems with different access controls than the application database.
The verification should not surface specific failure reasons in HTTP responses. A response of "invalid signature" is fine; a response of "signature mismatch, expected ABC, received XYZ" leaks information about the verification logic. The customer-facing error should be generic; the internal log can be more detailed for debugging.
The verification should not bypass signature checking based on source IP, user agent, or any header the sender controls. Allow-listing source IPs is sometimes proposed as defense-in-depth but is brittle (provider IPs change), provides false confidence (IP can be spoofed in some configurations), and is no substitute for proper signature verification.
Testing webhook verification
The four test cases that catch most verification bugs are: a valid signature, a valid signature with a tampered body, a valid signature with an old timestamp, and a missing signature. All four should produce the expected behavior: pass, reject, reject, reject. Most provider documentation includes signed test payloads that can be used for the first case.
A fifth test case worth adding: a duplicate event ID. This catches the idempotency bug. If the same event ID is processed twice and the side effect runs twice, the deduplication is broken.
The webhook test infrastructure that providers offer (Stripe CLI, WebhookVault's test mode, ngrok plus dashboard replay) is enormously valuable for these tests because the verification logic is sensitive to the exact byte representation of the payload, which is hard to construct correctly by hand.
The pattern
Webhook signature verification is small, security-critical, and easy to get subtly wrong. The bugs we have covered are not novel; they have been documented for years by Stripe, GitHub, Linear, and others. They continue to appear in receiver code because the size of the verification logic does not match the importance of getting it right. A 30-line function does not feel like the kind of thing that needs a security review, but it is exactly the kind of thing that does.
The pattern recurs across security-sensitive integration boundaries: the code is small, the failure modes are catastrophic, and the temptation to optimize for developer convenience over security is high. Webhook verification fits this pattern, as do OAuth callback handlers, JWT verification, and CSRF token validation.
Our use of these patterns across our four products: WebhookVault, our webhook capture and replay product, applies the receiver-side patterns we have covered (raw-body access, timing-safe comparison, timestamp validation, idempotency via event ID) to the captured webhook payloads. CronPing, FlagBit, and DocuMint all emit webhooks to customer-configured URLs and apply the sender-side patterns we have covered in prior posts. The pattern is symmetric: the discipline that makes you a trustworthy sender also makes you a discerning receiver.
Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) keep the lights on.