How Do You Know a Webhook Really Came From Who It Claims?
How Do You Know a Webhook Really Came From Who It Claims?
You verify a signature the sender computed with a secret only the two of you share. Nothing else in the request proves anything. The URL is public, the payload is easy to fake, and an unverified webhook endpoint is a button on the internet that anyone can press.
We find this missing more often than any other integration flaw. The webhook works, the demo passed, and nobody wrote the eight lines that check the signature. The endpoint then sits there accepting instructions from whoever finds the URL.
This walkthrough covers how verification actually works, the parts people get subtly wrong, and what else the endpoint owes you beyond a signature check.
What Happens If You Skip Verification?
Anyone who learns your endpoint URL can send it whatever they like. Stripe's own documentation spells out the consequence plainly: "Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records."
Read that list again. Those are the three things webhooks usually do. A payment webhook marks an order paid. An auth webhook provisions a seat. A CRM webhook writes a record. All three are exactly what an attacker would want to trigger for free.
The URL is not a secret either, so hoping nobody finds it is not a control. It appears in vendor dashboards, in logs, in support tickets, in browser history, and in whatever monitoring tool your team pasted it into. Treat it as public, because it is.
Stripe recommends two protections together rather than one. It names "IP allowlisting," because it sends events "from a set list of IP addresses," and "signature verification." We would add that the allowlist is the weaker of the two and should never be the only one.
How Does Signature Verification Actually Work?
The sender hashes the request body together with a shared secret and puts the result in a header. You repeat the same computation with your copy of the secret and compare. If the two match, the body is exactly what the sender signed, and only someone with the secret could have produced it.
Stripe's implementation is a good one to learn, because its documentation shows the whole thing. The signature arrives in the Stripe-Signature header, which "contains a timestamp and one or more signatures that you must verify." The timestamp carries a t= prefix, and each signature carries a scheme prefix beginning with the letter v.
The string you sign is specific and order matters. Stripe describes it as "the timestamp (as a string)," then "the character .", then "the actual JSON payload (that is, the request body)." You then "compute an HMAC with the SHA256 hash function," using the signing secret as the key and that string as the message.
GitHub does the same job with different names. Its documentation says the signature arrives in the X-Hub-Signature-256 header as an "HMAC hex digest," and that the value always begins with sha256=. Same mechanism, different header, so read the specific vendor's page rather than assuming.
One detail in Stripe's docs is easy to miss and worth honouring. It says the only valid live signature scheme is v1, that a fake v0 scheme is sent with test events, and that to prevent "downgrade attacks" you should "ignore all schemes that aren't v1." Accepting any scheme the sender offers defeats the purpose.
Why Does the Raw Body Matter So Much?
Because the signature covers the exact bytes that were sent. If your framework parses the JSON and you re-serialise it before hashing, you will produce a different string and every verification will fail, even though nothing is wrong.
Stripe states the requirement directly. It "requires the raw body of the request to perform signature verification," and warns that if you are using a framework, "make sure it doesn't manipulate the raw body." It adds that "any manipulation to the raw body of the request causes the verification to fail."
This is the number one reason a correct implementation appears broken. The developer reads the parsed body because that is what every other route does, the signature never matches, and the temptation is to skip the check to unblock the demo. That temptation is how endpoints ship unverified.
The fix is framework specific and always small. Capture the raw body on that route before any body parser touches it, verify, and only then parse. Our guide to Webflow webhooks covers the equivalent step in that context.
What Is a Replay Attack, and How Do You Stop It?
A replay attack is when someone captures a legitimate signed request and sends it again later. The signature is genuinely valid, because it was valid the first time. You stop it by refusing requests whose timestamp is too old.
Stripe explains why this works. The timestamp is inside the signed payload, so "an attacker can't change the timestamp without invalidating the signature." That gives you a trustworthy age for the request, and you can reject anything older than a window you choose.
The window matters. Stripe's libraries "have a default tolerance of 5 minutes between the timestamp and the current time," which is a sensible default for most systems. Its documentation is also emphatic about one mistake: "Don't use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely."
That counterintuitive note catches people who assume zero means strictest. It means no check at all. And because the whole mechanism depends on your clock, Stripe recommends using Network Time Protocol to keep the server's clock accurate. A server drifting by ten minutes will reject every valid event.
Why Can You Not Just Compare the Two Strings?
Because a normal string comparison returns as soon as it finds a mismatched character, so it takes measurably longer for a signature that shares a longer prefix with the correct one. Repeated enough times, that timing difference leaks the signature.
GitHub's documentation is unusually direct about this. It says "never use a plain == operator," and to "instead consider using a method like secure_compare or crypto.timingSafeEqual, which performs a 'constant time' string comparison."
Stripe gives the same instruction in its manual verification steps, telling you "to protect against timing attacks, use a constant-time-string comparison to compare the expected signature to each of the received signatures." Two independent vendors calling out the same line of code is a strong signal.
In practice this is the single easiest thing to get right, because every mainstream language ships a function for it. It is also the most commonly skipped, because a plain equality check appears to work perfectly in every test you would think to write.
What Else Should the Endpoint Do Besides Verify?
Answer fast and do the work later. Stripe's guidance is that your endpoint "must quickly return a successful status code (2xx) before any complex logic that could cause a timeout," and gives the example of returning a 200 before updating an invoice in your accounting system.
So the shape of a good handler is short. Verify the signature, write the raw event to a queue or a table, return 200. Everything else happens in a worker. Stripe's own best practices recommend exactly that, noting that processing events synchronously creates scalability problems when deliveries spike.
A few smaller things belong on the checklist too. The route needs exempting from CSRF protection in frameworks that apply it to every POST, or legitimate events get rejected. Redirects are fatal: Stripe treats a 302 or any other 3xx response to a webhook as a failure. And in live mode it supports only TLS versions 1.2 and 1.3, so an old TLS configuration silently breaks delivery.
Subscribe narrowly as well. Stripe advises configuring endpoints "to receive only the types of events required by your integration," and notes that listening for everything "puts undue strain on your server." Fewer events means fewer things to reason about. Our broader website security guide covers the surrounding hardening.
How Do You Handle Duplicates and Out of Order Events?
Assume both will happen and design for it. Webhook delivery is at least once, not exactly once, and the order is not guaranteed. An endpoint that assumes otherwise breaks in production during the first busy hour.
Stripe says endpoints "might occasionally receive the same event more than once," and recommends guarding against it "by logging the event IDs you've processed, and then not processing already-logged events." That is a small table and a unique index, and it removes a whole class of bug.
On ordering, its documentation is explicit that it "doesn't guarantee the delivery of events in the order that they're generated," and gives a subscription creation example that emits several related events. It also warns against a specific shortcut: "Don't use created to determine event order or whether you've already processed an event," because distinct events can share a timestamp.
Retries make duplicates likely rather than theoretical. Stripe retries delivery "for up to three days with an exponential back off in live mode," and a successful handler that timed out after doing its work will see the same event again. We covered the delivery trade offs in our piece on webhooks versus polling.
How Do You Rotate a Signing Secret Without Downtime?
Accept two secrets during the change. Stripe supports this directly: when you roll a secret you can "immediately expire the current secret or delay its expiration for up to 24 hours," and during that window "multiple secrets are active for the endpoint" with "one signature per secret until expiration."
So the safe sequence is to create the new secret, deploy code that tries both, wait for the old one to expire, then remove it. No event is rejected and no deploy has to be perfectly timed.
Stripe also recommends rolling secrets "periodically, or when you suspect a compromised secret." We agree, and in our experience the periodic version only happens if someone puts it on a calendar. A secret created during a launch three years ago is still in production at most companies we look at.
What Would We Check on Your Endpoint First?
Three things, in about ten minutes. Send an unsigned POST to the endpoint and see whether it returns an error or cheerfully does the work. Look at whether the handler reads the raw body or a parsed one. And check whether the comparison uses a constant time function.
If the unsigned request succeeds, stop reading and fix that today. It is the difference between an integration and an open API that performs privileged actions for strangers.
After that, the queue and the duplicate table are what turn a working endpoint into a reliable one. Neither is a big piece of work, and both are much cheaper to add now than after the first reconciliation problem.
If you want a second pair of eyes on an integration that handles payments, provisioning, or customer records, we are happy to review it with you. You can find us at phoenix.studio.
Want a site that performs like this?
Tell us about your project. We will come back with a clear next step, no pressure.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
Have a project like this?
Tell us where you want to go. We'll tell you how we'd get you there.