Field note
Stripe webhooks with no signature check
Stripe signs every webhook in the Stripe-Signature header. A handler that returns 2xx for an empty POST with no signature has already skipped that check.
A Stripe webhook is a POST from Stripe to your server. The body is an event. The proof that Stripe sent it is the Stripe-Signature header, checked against the raw body and the endpoint's whsec_ secret. Skip that check and anyone who can find the URL can POST a JSON object your handler will treat as a paid invoice, a completed checkout, or a deleted customer.
Stripe's signature guide says verification uses the JSON payload, the Stripe-Signature header, and the endpoint secret. If verification fails, you get an error. The guide also says not to manipulate the raw body before that check. A framework that parses JSON first and then re-serializes it will fail a real Stripe delivery, which is how people "temporarily" remove the check and forget to put it back.
This note is that failure mode, and the read the free scan runs once a domain is verified. The finding is "Stripe webhook accepted a POST with no signature." A correct handler, including the one on this site, returns 400 when the header is missing. That response is not a finding.
What Stripe puts on the request
The header carries a timestamp and one or more signatures. Stripe's guide describes a t= timestamp and a v1 signature over the payload. The official libraries take the raw body, the header, and the secret, and they throw when the signature does not match. There is no supported path where a missing header still yields an event.
The timestamp is part of the signed payload so a captured, valid delivery cannot be replayed forever. The guide says that if the signature is valid but the timestamp is too old, your application can reject the payload. That check happens after a signature matches. It does not run if you never look at the header.
The secret is per endpoint. It starts with whsec_. It is not the publishable pk_ key and it is not the secret API key, though a leaked whsec_ is enough to forge events that pass verification. The rotation note includes webhook signing secrets in the list of values to roll at the issuer if they lived on a host. The Clerk note makes the same point for a whsec_ value that ended up in client JavaScript. Neither of those is this bug. This bug is the handler accepting a request that never had a signature.
Return 400 when the header is missing or the helper throws. Return 2xx only after constructEvent (or the equivalent in Stripe's library for your language) has returned an event, and only after you have decided that event is one you handle. A 2xx tells Stripe the delivery succeeded. Stripe will not retry a 2xx. A 400 tells Stripe the delivery failed. Those are different outcomes, and a handler that returns 200 to an unsigned body has recorded a success for a request Stripe did not send.
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
const body = await request.text();
if (!signature) {
return new Response("Missing stripe-signature", { status: 400 });
}
// Pass body, signature, and the server-only whsec_ secret to
// Stripe's verifier. On failure, respond 400 and do not write.
}Read the body with request.text() or the raw buffer your framework documents. Do not run it through request.json() and then stringify it again. The signature is over the bytes Stripe sent. The secret stays in a server-only env var. NEXT_PUBLIC_ and the env key in next.config would put it in the client bundle, which is a different finding and a reason to roll the secret.
What the scan sends, and what it refuses to send
The check runs on a verified full scan, after the domain is claimed. A preview of an unverified URL does not POST.
It looks at the page and its scripts for webhook URLs on that same host. Three pathnames count, and only when they are the whole path: /api/stripe/webhook, /api/webhooks/stripe, and /api/webhook/stripe. An absolute https URL counts only when its host is the host being scanned. A path that appears only inside some other site's URL is not rewritten onto this origin. A prefix such as /foo/api/stripe/webhook does not count. Query strings are stripped. At most two URLs are tried.
For each one, the request is an empty POST. Content-Length is 0. There is no Stripe-Signature header. There is no event type, no id, and no fabricated checkout.session.completed. Redirects are not followed. The response body is cancelled and not stored. The timeout is five seconds.
A 2xx is the finding. The evidence line is the status code, and the location is the URL that returned it. 400, 401, 403, 404, 500, and any redirect are silence. Silence means this request did not observe an accept. It does not mean the handler verifies signatures on a well-formed, signed body. It means the endpoint refused an empty unsigned POST, which is the behavior Stripe's failure mode describes.
The same origin is limited to two of these attempts per minute. When that limit is already spent, the probe is skipped and the scan continues. It does not keep posting until something answers.
This site's own webhook returns 400 with {"error":"Missing stripe-signature"} for a POST that has no header. That is the result the probe treats as quiet.
Why edge functions are not called
Supabase's function configuration uses a Stripe webhook as the example for verify_jwt = false. Stripe does not have your user's JWT, so the gateway check that expects one would reject a real delivery. The same page says to be careful: with that flag off, anyone can invoke the function.
A scan that treated verify_jwt = false as a finding would flag the setup Supabase documents for Stripe. A scan that invoked the function would run whatever the function does, on a project that turned the JWT check off specifically so Stripe could call it. Neither is a safe probe. The check that stays safe is the HTTP one above, aimed at the app URL the page already named, with a body that carries no event.
If your webhook is a Supabase edge function, verify inside the function the same way you would in a route handler. Read the raw body. Pass it, the Stripe-Signature header, and the whsec_ secret to Stripe's library. Return a non-2xx error when that throws. Do not update a row, grant a plan, or send mail before that call returns an event. The gateway flag and the signature check solve different problems. The flag lets Stripe reach the function. The signature check proves the caller is Stripe.
What a 2xx usually means in a vibe-coded handler
The handlers that accept an unsigned POST tend to share a shape. They parse JSON, switch on type, and write. The signature block was left as a comment, or it was copied from a tutorial that used the secret in the browser, or the route was generated to "make checkout work" and the first test was a curl with a made-up event. A 200 from that route is the generator telling you the happy path ran.
Fix the order. Verification is the first thing the function does. The database is after. Use the official helper rather than a substring check for the header's presence. A header that is present and wrong is still a failure. The helper returns the error Stripe's guide describes. Your code returns 400 and stops.
Keep the signing secret out of the client. Roll it in the Stripe dashboard if it has been in a bundle, a chat log, or a host you no longer trust, then put the new value only in the server env and redeploy. The rotation note is the list of neighboring secrets. Rolling whsec_ does not fix a handler that never reads it. Deploy the check, then roll, or the new secret sits unused while the old route still returns 200.
Idempotency is the next line, after verification. Stripe retries deliveries. A verified event can arrive twice. Store the event id and ignore a duplicate. That logic is worthless if it runs on an unsigned body, because the attacker chooses the id. Put it behind a successful verify.
Do not log the raw body on failure. A real Stripe delivery contains customer identifiers. The scan does not keep the body of its empty POST. Your 400 path does not need to either.
After you change the route
Redeploy, then rescan the verified domain. The finding names the URL that still returned 2xx. A 400 on the empty unsigned POST clears it. Confirm a real Stripe test delivery still returns 2xx after verification, using the dashboard's send-test-event control or the CLI against your own endpoint. That delivery includes a signature. The probe will never send one, so a route that rejects everything, including valid signatures, is quiet on the scan and broken in production. Watch one signed test event after the 400 is in place.
The free scan runs this only after verification, and only against the paths above when they appear on the site. The fix prompt on the finding is the same instruction the report shows: verify with the official helper, return 400 on failure, keep the secret server-side. Paste it into Lovable, Cursor, or Claude against this route, not against a guessed URL.