Across 21 public and held-out third-party apps I went through over June and July 2026, three had a real secret permanently in their git history; in two, the leaked secret included a webhook signing secret. Webhooks security rests on that secret: the endpoint is a public URL anyone can post to, and a signature checked against the raw request body is how it knows an event came from Stripe.
Webhooks security: what it is, and the six checks a receiving endpoint runs
Webhooks security is the set of checks a receiving endpoint runs before it trusts an incoming event. There are 6: accept HTTPS only, verify the provider’s signature on the raw body, reject stale timestamps, process each event id once, acknowledge fast and work afterwards, and keep the signing secret per endpoint, out of the repository and rotatable.
That count of 3 comes from a selected set: the 21 are the 11 public and 10 held-out third-party apps I audited in June and July 2026, not a random sample, so it is no rate for AI-built apps in general. Signature checks still come first among my SaaS billing process best practices, because every later billing check trusts whatever the webhook wrote.
A webhook endpoint is a public route on your server that a provider such as Stripe, GitHub or Paddle calls with an HTTP POST. It sits outside your login system by design: no session cookie, no user token. Its only authentication is the signature. This page assumes you know what a webhook is and how it differs from an API call your own code makes; it covers the receiving end.
Whether webhooks are secure depends on which half you mean. The transport is as safe as HTTPS makes it, and the endpoint is exactly as safe as the checks it runs before it acts. The webhook security checklist below follows the order a handler runs it in; the order is my working rule.
| # | check | what it stops | where it lives in the handler |
|---|---|---|---|
| 1 | Accept HTTPS only | a delivery read or changed in transit | the registered URL and the host, before your code runs |
| 2 | Read the raw body and verify the signature before parsing | forged events and altered payloads | the first lines of the route, before any JSON parser |
| 3 | Reject a stale timestamp | a captured, validly signed request sent again later | inside the signature check, where the provider signs a timestamp |
| 4 | Look up the event id and stop if it was already processed | the same event applied twice after a retry or a resend | after verification, before any business logic |
| 5 | Acknowledge with a 2xx quickly, do slow work afterwards | timeouts that trigger retries and duplicate work | the end of the request path, with a queue or background job behind it |
| 6 | Keep the secret clean: an environment variable, never in the repository, rotated on any leak, one per endpoint | a leaked secret that lets anyone sign events | your host’s secret settings and the provider’s dashboard |
Stripe requires registered webhook endpoints to be publicly accessible HTTPS URLs, and tells you to return a 2xx before any complex logic that might cause a timeout. The fourth check is the subject of how to make a webhook handler idempotent, so it gets one line here. On the sixth, Stripe webhook signing secrets are per endpoint and separate from API keys, so the same URL has different test and live secrets.
Two more habits belong with webhook security best practices but sit outside the six, as my working rules: subscribe only to the event types you handle, and treat the payload as a notice, fetching the object from the provider’s API before granting anything of value. Stripe makes the first point itself: configure each endpoint “to receive only the types of events required by your integration.” Knowing how to secure webhooks on the receiving end comes down to running those six checks, in that order, before the event reaches your database.
What goes wrong without it
Anyone can post to your webhook endpoint. That is true of every webhook endpoint on the internet, and it is harmless only when unsigned requests are rejected. A handler that parses the JSON and acts on it is what makes a demo work on the first try, which is why that shape is easy to ship (my reading). The table shows four handlers and what a stranger can do to each; none of it is a report of a real attack on your app.
| what the handler does | what a stranger can do | what it costs |
|---|---|---|
| Parses the JSON and acts, with no signature check | post any event body to the public URL | every state change the handler makes is open to anyone |
| Trusts a “checkout completed” event at face value | hand-write that event with a real user’s id in it | the account moves to the paid plan with no payment |
| Verifies, but the signing secret sits in the repository | sign a forged event with the leaked secret | verification passes, so the check protects nothing |
| Verifies, but runs with an empty secret | compute a valid signature without knowing any secret | the same as having no check at all |
The second row is the one that costs money directly: a fake webhook granted paid access to an account the moment the handler believed it. Forged events can wrongly grant paid access or change account state. The second defense is my working rule: paid features check entitlement on the server against the provider’s record, never against a flag the webhook set, which is the core of how to enforce plan limits on the backend.
The third row is where the opener’s count lands. In the same audits, one app’s git history held a webhook signing secret, and another’s held a Stripe test key with its webhook secret. With the secret in hand, a forged event passes verification. Rolling a leaked Stripe secret has its own steps; the raw-body section below points to them.
The fourth row has a public record. The GitHub Advisory Database entry for New API, a Go project hosted on GitHub (QuantumNous/new-api), was published in the project’s repository on April 22, 2026 and published to the GitHub Advisory Database and reviewed on April 24, 2026. It says the Stripe webhook endpoint did not reject requests when the Stripe webhook secret was empty, which was the default, so any attacker could compute valid webhook signatures; the handler also checked only that the checkout session was complete, not that it was paid, and did not check that an order’s payment method matched the callback source. Version 0.12.10 includes all three fixes, the first rejecting webhooks when the secret is empty. The lesson I take from it: a signature check is only as strong as the secret it is given, so a handler whose secret can be empty should refuse every request, which is the advisory’s own first fix.
Tests were no safety net in those apps either. Of the same 21 third-party apps, only 1 was credited with a real test suite, and even it skipped sign-up, login and the payment webhook. Those are the public and held-out apps from my June and July audits, picked rather than sampled at random, so the figure describes that set and not AI-built apps at large. Whether your own endpoint rejects a forgery is something the six requests further down decide.
How to do it: signing, the raw body, replay, allowlists, and the hooks.stripe question
Five parts follow, from how a signature is made to what hooks.stripe.com is. Start without a terminal. In the provider’s dashboard, open the webhook endpoint’s page and confirm two things: a signing secret exists, and recent deliveries show 2xx responses. In Stripe, open Workbench, select the endpoint under Webhooks and open the Event deliveries tab, which lists each event as Delivered, Pending or Failed and shows the HTTP status code of each attempt; the secret sits behind Reveal secret on the endpoint’s page.
Then search your repository for the secret’s variable name. STRIPE_WEBHOOK_SECRET is the environment-variable name Stripe uses in its own webhook samples, in every language. If nothing reads it, no verification happens. Stripe’s quickstart sample also carries the comment “Only verify the event if you have an endpoint secret defined,” and wraps its check in if (endpointSecret). A copy of that sample deployed without the variable acts on events it never verified (my reading). My working rule: if the variable can be empty at boot, the handler refuses every request, which is the New API lesson from the section above.
Webhook signature verification: how signing works, and webhook authentication best practices
Webhook signature verification proves that an event came from the provider and was not altered. The provider computes an HMAC, usually SHA-256, over the raw payload with a secret shared only with you, and sends it in a header. The receiver recomputes it, compares in constant time, and rejects any mismatch before parsing.
What gets signed differs by provider. Stripe signs the timestamp, a period and the request body; Paddle signs the timestamp and the raw body joined by a colon; GitHub’s header is the HMAC hex digest of the request body. HMAC is the method you will meet most, but not the only one. In the table, the header names and the who-uses-it column come from the providers’ docs, and the weakness column is my reading.
| method | how it proves the sender | who uses it | weakness |
|---|---|---|---|
| HMAC with a shared secret in a header | both sides hold one secret; a matching HMAC over the body proves the holder sent those bytes | Stripe (Stripe-Signature), GitHub (X-Hub-Signature-256), Paddle (Paddle-Signature), Lemon Squeezy (X-Signature) | anyone who holds the secret can sign |
| Asymmetric signature, verified with the provider’s public key | the provider signs with a private key; you verify with its public key | Twilio SendGrid’s Signed Event Webhook (ECDSA) | more setup; you have to fetch and keep the right public key |
| Static token in a header or the URL | the receiver compares a shared value | not stated in the docs checked | no integrity, so the body can change; tokens in URLs end up in logs |
| Basic auth | a username and password on each request | not stated in the docs checked | proves who knew the password, not what the body says |
| Mutual TLS | the receiver checks a client certificate during the TLS handshake | not stated in the docs checked | certificates to issue and renew on both sides |
Each header and scheme above is from the provider’s own page: Stripe’s webhook documentation, GitHub’s guide to validating webhook deliveries, Paddle’s signature verification docs and Lemon Squeezy’s guide to signed requests. GitHub still sends an older X-Hub-Signature header, which “uses the HMAC-SHA1 algorithm and is only included for legacy purposes.” Among webhook authentication best practices, one fits in a line: use the provider’s official library to verify where one exists, and hand-roll HMAC only when none does (my working rule). Stripe says the same of its own: “We recommend using our official libraries to verify signatures.” OWASP now publishes a Webhook Security Cheat Sheet, which calls forged deliveries the headline threat and signature verification the first control to implement.
Verify the signature against the raw request body: the rule, and a Node.js check
The signature must be verified against the raw request body, byte for byte, because the HMAC was computed over exactly those bytes. A framework that parses JSON first and re-serializes it can change whitespace or key order, and the check then fails with a valid secret. My working rule: read the body as text, verify, then parse.
So the webhook route reads the body as text or a buffer before any JSON parser touches it. Stripe’s signature troubleshooting page lists what breaks this: some frameworks might edit the request body by “adding or removing whitespace, reordering the key-value pairs, converting the string to JSON, or changing the encoding,” and every one of those leads to a failed verification. Paddle’s docs give the same rule for its own signature.
To verify a webhook signature in Node.js without a provider library, the check below follows the Node.js crypto documentation; it is written from those docs, not shown as tested output. The header name and the signed string are placeholders, because each provider defines its own.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: a Buffer read before any JSON parser touches the request
export function isValidSignature(rawBody, receivedHex, secret) {
if (!secret) throw new Error('Webhook secret missing: refuse every request');
// signedString: <signed string per the provider's docs>, built from rawBody
const signedString = rawBody;
const expected = Buffer.from(
createHmac('sha256', secret).update(signedString).digest('hex'), 'utf8');
// receivedHex: the value from <signature header>, parsed per the provider's docs
const received = Buffer.from(receivedHex ?? '', 'utf8');
// timingSafeEqual throws if the byte lengths differ, so compare lengths first
if (received.length !== expected.length) return false;
return timingSafeEqual(received, expected);
}
The Node.js docs say timingSafeEqual throws an error when its two inputs have different byte lengths, which is why the length check comes first. They add a caveat worth keeping: using crypto.timingSafeEqual does not guarantee that the surrounding code is timing-safe. On Stripe, a failed signature check reports Webhook signature verification failed. Err: No signatures found matching the expected signature for payload. when at least one of the check’s three inputs is wrong: the endpoint secret (the most common error), the raw body, which a framework may have mutated, or the signature header. The Stripe fix for Express, Next.js and Astro, Stripe’s replay and dedupe details, and what to do when the signing secret leaks are all in why Stripe webhook signature checks fail.
Replay windows, timestamps and rotating the secret
A replay window is the maximum age a signed webhook may have when it arrives. The provider signs a timestamp with the payload, and the receiver rejects anything older than the tolerance, which Stripe’s libraries set to 5 minutes by default. It stops a captured, validly signed request from being accepted again later.
Stripe explains why the window holds: the timestamp is part of the signed payload, so it is also verified by the signature, and an attacker cannot change it without invalidating the signature. The server clock has to be right for this to work, and Stripe recommends NTP to keep it in step with Stripe’s servers. Stripe also warns against a tolerance of 0, which “disables the recency check entirely.” Paddle’s SDK helpers are much tighter, with a default tolerance of five seconds between the timestamp and the current time. A signed timestamp is not stated in GitHub’s validating guide; what GitHub does send is X-GitHub-Delivery, “A globally unique identifier (GUID) to identify the event,” which your handler can record.
A replay that arrives inside the window still carries a valid signature, so it is the event id check, the fourth of the six, that stops it. Rotation is the other half. My working rule is to rotate the signing secret when a developer or an agency leaves, and on any suspected leak. Each provider documents its own roll, and Stripe’s roll steps, including the overlap while old and new secrets are both active, are in the Stripe article from the raw-body section.
IP allowlists are not signature verification: Stripe webhook IP addresses and where providers publish ranges
An IP allowlist is not signature verification. It checks where a request came from, not what it says, and a provider’s published ranges are shared by every customer of that provider. Stripe recommends allowlisting its published webhook IP addresses in addition to signature verification, not instead of it. Treat the allowlist as a second lock.
Stripe prints its webhook IP addresses under “Webhook notifications” on Stripe’s list of domains and IP addresses, with ips_webhooks.txt and ips_webhooks.json copies for firewall import, and gives seven days’ notice of changes through its API announce mailing list. GitHub lists its hook ranges under the hooks key of GitHub’s meta endpoint, and its own docs say “We do not recommend allowing by IP address, but if you use these IP ranges we strongly encourage regular monitoring of our API.”
| provider | where the ranges are published | how they change |
|---|---|---|
| Stripe | docs.stripe.com/ips, “Webhook notifications”, plus ips_webhooks.txt and ips_webhooks.json | announced on the API announce mailing list, seven days ahead |
| GitHub | GET /meta, the hooks key | ”from time to time”; query the API for the latest values |
| Paddle | ”Allow Paddle IP addresses” on its webhook delivery page, with separate sandbox and live lists | not stated in Paddle’s docs |
| Lemon Squeezy | not stated in Lemon Squeezy’s webhook docs | not stated in Lemon Squeezy’s docs |
An allowlist alone fails for a plain reason (my reading): it tells you a request came from the provider’s network, not which account it is for or whether its body was changed, and every other customer of that provider sends from the same ranges. It can also be impractical. On serverless hosts a per-route IP filter may not be available, and a list that goes stale drops real events. My verdict: the signature is the first lock. Stripe asks for the allowlist as well, as the capsule above says, and where your host cannot filter by IP, the signature still has to hold on its own.
What hooks.stripe.com is, and why you verify the payload instead of trusting the sender
hooks.stripe.com is one of the domain names Stripe lists as its own, among those it uses to interact with an integration. For a server, the sender’s name proves nothing, because headers and names can be written by anyone: the payload’s signature is what an endpoint checks.
That list, on docs.stripe.com/ips, prints no description next to the name. A cardholder may see the name during card authentication: Stripe’s 3D Secure guide shows the customer being redirected to a https://hooks.stripe.com/... address to complete authentication, and tells sites with a content security policy to allow iframes from it. None of that helps a server decide what to trust. A domain name in a prompt, an email or a request header is text anyone can type (my reading), which is the whole case for checking signatures.
How to verify it
Webhook security is verified with 6 requests against your own endpoint: a genuine test event is accepted, and an unsigned request, an altered payload, a stale timestamp, a signature made with the wrong secret and a repeated event are each rejected or ignored. After the 5 failures, the database must show no change.
Run them in test mode against staging or a local tunnel, never against an endpoint you do not own. Every test that sends a webhook with an invalid signature has to end in a rejection and an unchanged database. Sign where needed with the Node.js check above and the provider’s documented scheme, using the endpoint’s test secret; Stripe’s signed string is the one described in the signing section.
- 01 A genuine event. Complete a test-mode checkout in the app itself with a test card. Pass: a 2xx response and the expected change. Evidence: the delivery in the provider's event log and the changed row.
- 02 No signature. Copy a real payload and send it with curl as a POST with no signature header. Pass: a 4xx response. Evidence: the status code and the log line with the reason.
- 03 An altered payload. Sign a payload with the endpoint's test secret, then change one character of the body after signing. Pass: a rejection. Evidence: the status code and the log line.
- 04 A stale timestamp. Sign a request correctly, with a signed timestamp older than your tolerance. Pass: a rejection. Evidence: the status code and the log line.
- 05 The wrong secret. Sign a request with a different secret, such as another endpoint's. Pass: a rejection. Evidence: the status code and the log line.
- 06 The same event twice. Resend the event from the first request through the provider. Pass: two deliveries of the same event id in the delivery log and one business effect. Evidence: both log entries and the single changed row.
For the first request, use the app rather than the Stripe CLI: Stripe CLI trigger commands create test fixtures before causing webhook deliveries, and a fixture belongs to no user of your app, so a triggered event shows the 2xx but not the change (my reading). The fourth request applies only where the provider signs a timestamp. For the fifth, remember that a live Stripe webhook destination has its own signing secret even when its URL matches a testing destination. For the sixth, Stripe’s Dashboard Resend “works for up to 15 days after the event creation,” and stripe events resend <event_id> --webhook-endpoint=<endpoint_id> works “for up to 30 days.” One delivery proves nothing for that request (my reading), and a full idempotency test goes beyond this one resend.
After requests 2 to 5, query the database: no row changed, no plan granted, and each rejection is in the logs with a reason and without the payload’s personal data. Keep the evidence together: the six responses with their status codes, the database check, the log lines and the date. Tools for sending, forwarding and replaying events locally are part of how to test webhooks; the rest of what has to be true before the first real payment is on the payment go-live checklist.
On the Production Hardening Sprint, deliverable 5.1 is verified this way: confirm valid events succeed and invalid or altered payloads are rejected.
Where the sprint does this
Sprint deliverable 5.1 verifies the payment provider’s signatures before processing webhook payloads; its verify line is the one quoted under How to verify it. Deliverable 5.2 makes every webhook handler safe to repeat, including concurrent delivery and its downstream side effects, and it is verified this way: replay and concurrently deliver events; confirm one intended business effect. Both land in the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Where it stops: formal third-party certifications and independent audit opinions are separate from these engineering deliverables. The wording for each sits with the payment checks in the published scope.
Common questions about securing webhooks
What are the downsides of using webhooks?
The main downsides are a public endpoint you have to defend, deliveries that can repeat or arrive out of order, and silent gaps while your endpoint is down. Stripe can retry failed live deliveries for up to three days and does not guarantee event order. Each has a control: the signature for the open endpoint, the event id check for repeats, and the provider’s delivery log for gaps.
How can I verify a signature online?
Verify it locally instead, with the provider’s library or a few lines of your own code, and never paste a signing secret or a live payload into an online webhook signature validator. Whoever runs that site could keep the secret, and with it they can sign events your endpoint will accept. If a secret was pasted somewhere already, roll it (my working rule).
Is hooks.stripe.com a legitimate website?
Yes, as a domain: hooks.stripe.com is on Stripe’s published list of its own domain names, and Stripe’s 3D Secure guide shows card authentication redirecting through it. That tells a cardholder the domain belongs to Stripe, not that a particular charge is genuine, so check the charge in your bank’s app and contact the merchant named there.
Is verify.stripe.com legitimate?
Yes, verify.stripe.com appears on Stripe’s published list of the domain names it uses, though the list gives no purpose for it. The safe habit for a person is to reach it from the merchant’s own flow, never from a link in a message (my working rule).
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase