In short
- Each webhook endpoint has its own
whsec_secret, a different one in test and live mode, andstripe listenprints another for local work. A signature error usually means the wrong one. - Verify against the raw body.
express.json()before the route, orreq.json()in Next.js, changes the bytes: useexpress.raw()orawait req.text()rather than removing the check. - A missing or empty secret must stop the handler. In CVE-2026-41432, an empty default secret let anyone sign a fake Stripe event and credit their account without paying.
- A valid signature does not stop duplicates. In live mode Stripe retries for up to three days, so record
event.idand make fulfillment run once. - Test it: an unsigned event sent to your own webhook route must get a 400. A 2xx means anyone can fake a payment.
On this page
- What the Stripe webhook secret is
- Where to find your Stripe webhook secret
- What happens when a webhook skips the signature check
- How Stripe-Signature works: timestamp, HMAC and tolerance
- Verify Stripe webhooks in Next.js and Express: the raw body
- Clerk webhook verification with Svix
- Idempotency and replay: a valid event can arrive twice
- Test that your webhook refuses unsigned events
- The traps after the signature check is in
- What Keybrum tests on your webhook routes
- Questions
- Sources
What the Stripe webhook secret is
When you add a webhook endpoint in Stripe, Stripe creates a signing secret for it: a string that starts with whsec_. Every event Stripe sends to that endpoint carries a Stripe-Signature header computed with that secret. Your server holds the same secret, recomputes the signature from the request it received, and compares. A match proves two things: Stripe sent the event, and nobody changed a byte of it on the way.
The secret only signs events. It cannot call the Stripe API; your API keys do that. How the four Stripe values in a typical project compare:
| Value | Starts with | What it does | Where it belongs |
|---|---|---|---|
| Publishable key | pk_live_, pk_test_ | Identifies your account to Stripe.js and the mobile SDKs. Cannot create charges or read account data. | Browser, fine to expose |
| Secret key | sk_live_, sk_test_ | Calls every Stripe API with no limit: charges, refunds, customers. | Server only |
| Restricted key | rk_live_, rk_test_ | Calls only the APIs you allowed. | Server only |
| Webhook signing secret | whsec_ | Lets your server check that an event really came from Stripe. | Server only |
whsec_ value in your frontend bundle is a leak: see API keys exposed in the frontend.Where to find your Stripe webhook secret
- 1
Dashboard endpoints: the Webhooks tab in Workbench
Open the Webhooks tab in Workbench, which replaces the old Developers Dashboard. Select your endpoint and reveal its signing secret.
- 2
One secret per endpoint, and per mode
Stripe generates a unique secret for each endpoint. If the same URL receives test and live events, each mode gets its own secret, so production needs the live one.
- 3
Local development: the Stripe CLI
stripe listen --forward-to localhost:3000/api/webhooks/stripeprints a line likeYour webhook signing secret is whsec_.... That secret is not the Dashboard one. Stripe's troubleshooting guide names the wrong secret as the most common cause of signature errors. - 4
Store it on the server
Put it in a server environment variable such as
STRIPE_WEBHOOK_SECRET. Never give it aNEXT_PUBLIC_orVITE_prefix: those values are copied into the JavaScript every visitor downloads.
What happens when a webhook skips the signature check
A webhook route has to be public: Stripe cannot sign in to your app, so the route has no session check. Clerk and Supabase document the same for their webhook routes, and Supabase warns that a function with JWT checks off can be invoked by anyone. That leaves the signature as the only lock on a route that changes money and access.
Stripe's documentation says it plainly: without verification, an attacker could send fake events to trigger actions like fulfilling orders, granting account access or modifying records. The path is rarely hidden: most routes keep the tutorial's name, such as /api/webhooks/stripe. What a handler that trusts the body does with a forged event:
| A forged event that says | What a handler that trusts the body does |
|---|---|
checkout.session.completed with payment_status: "paid" | Marks the order paid, ships it, or opens the course. |
invoice.paid or customer.subscription.updated | Upgrades a plan or extends a subscription nobody paid for. |
payment_intent.succeeded with a user id in metadata | Credits a balance, or AI credits that cost you real money on your provider's bill. |
customer.subscription.deleted | Cuts off a real customer's access. |
Clerk user.created, user.updated or user.deleted | Creates, edits or deletes rows in your own users table. |
How Stripe-Signature works: timestamp, HMAC and tolerance
The header looks like this, on a single line (this example is from Stripe's docs):
Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39- `t` is the Unix timestamp of this delivery attempt, in seconds.
- `v1` is the signature: an HMAC with SHA-256, keyed with your endpoint's secret, over the string made of
t, a dot, then the raw body. While you roll a secret, the header carries onev1per active secret. - `v0` is a fake scheme Stripe adds to test events. Ignore every scheme other than
v1: Stripe says this prevents downgrade attacks.
To verify, your server rebuilds t.body, computes the HMAC with its copy of the secret, and compares it to each v1 in constant time, so response timing reveals nothing. Then it checks the age: if t is older than the tolerance, the event is refused even with a valid signature. Stripe's libraries default to five minutes (300 seconds in stripe-node).
That timestamp is the defense against replay: a captured signed request cannot be sent again an hour later, because t is inside the signature. Stripe signs each delivery attempt again with a fresh timestamp, so its own retries still pass. Two things undo this: a tolerance of 0, which Stripe warns disables the recency check entirely, and a server clock that drifts (Stripe says to sync it with NTP).
Stripe now recommends thin events for new integrations: the payload carries only the event type and the related object's id, and your code fetches the current state from the API. That makes a forged event far less useful, but you still verify it: stripe.parseEventNotification() takes the same raw body, header and secret.
Verify Stripe webhooks in Next.js and Express: the raw body
The signature covers exact bytes. A framework that parses the JSON before your code runs can reorder keys, strip whitespace or change the encoding, and verification then fails with No signatures found matching the expected signature for payload. Pass an already parsed object to stripe-node and the error is explicit: the payload must be provided as a string or a Buffer.
Next.js App Router route handler
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) return new Response("Webhook secret not configured", { status: 500 });
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing Stripe-Signature header", { status: 400 });
const body = await req.text(); // the raw body, exactly as Stripe sent it
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, secret);
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
// Only now is the event trusted.
if (
event.type === "checkout.session.completed" ||
event.type === "checkout.session.async_payment_succeeded"
) {
await fulfillCheckout(event.data.object.id); // see "Trusting the event body" below
}
return new Response("ok", { status: 200 });
}await req.text() returns the body as sent, and the App Router needs no bodyParser setting, as the Next.js docs note. Parse nothing before constructEvent(). On the Pages Router, turn off bodyParser for the route and pass the raw buffer, as in Stripe's Pages Router example. The Next.js security guide covers the other routes worth checking.
Express
Express runs middleware in the order you register it. If app.use(express.json()) comes first, the body is already an object when the webhook sees it. The pattern to avoid:
// Avoid: the body is parsed before the webhook sees it, and nothing is verified
app.use(express.json());
app.post("/api/webhooks/stripe", async (req, res) => {
const event = req.body; // anyone can post this JSON
if (event.type === "checkout.session.completed") {
await markOrderPaid(event.data.object.id);
}
res.sendStatus(200);
});The fix registers the webhook route before express.json(), with express.raw({ type: 'application/json' }), so req.body is a Buffer of the original bytes:
const express = require("express");
const Stripe = require("stripe");
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) throw new Error("STRIPE_WEBHOOK_SECRET is not set"); // fail closed
const app = express();
// 1. The webhook route first, with the raw body
app.post("/api/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], webhookSecret);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// event is verified: handle event.type here
res.json({ received: true });
});
// 2. JSON parsing for every other route, after the webhook
app.use(express.json());Supabase Edge Functions and other Deno runtimes
In Deno, stripe-node checks signatures with the Web Crypto API, which only works asynchronously, so call constructEventAsync() with the Web Crypto provider, as Supabase's own example does. Supabase Edge Functions require a valid JWT by default; a Stripe webhook function runs with that check off (verify_jwt = false), so it answers anyone and the signature is its only gate.
// supabase/functions/stripe-webhook/index.ts
import Stripe from "npm:stripe@^22";
import { withSupabase } from "npm:@supabase/server@^1";
const stripe = new Stripe(Deno.env.get("STRIPE_API_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider(); // Web Crypto, no Node crypto here
export default {
fetch: withSupabase({ auth: "none" }, async (req) => {
const secret = Deno.env.get("STRIPE_WEBHOOK_SIGNING_SECRET");
const signature = req.headers.get("Stripe-Signature");
if (!secret || !signature) return new Response("Refused", { status: 400 });
const body = await req.text();
let event;
try {
event = await stripe.webhooks.constructEventAsync(body, signature, secret, undefined, cryptoProvider);
} catch (err) {
return new Response((err as Error).message, { status: 400 });
}
// event is verified
return Response.json({ received: true });
}),
};Clerk webhook verification with Svix
Clerk sends its webhooks through Svix. The idea is the same as Stripe's, but the headers, the signed string and the key differ, as the table below shows. Both secrets start with whsec_, yet Svix uses only the base64 part after the prefix as the key, so Stripe's verifier cannot check a Clerk event, or the reverse.
// app/api/webhooks/clerk/route.ts
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import type { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
let evt;
try {
// Reads CLERK_WEBHOOK_SIGNING_SECRET and the raw body, checks the svix-* headers
evt = await verifyWebhook(req);
} catch {
return new Response("Invalid webhook signature", { status: 400 });
}
if (evt.type === "user.created") {
// upsert the user by evt.data.id, once (see idempotency below)
}
return new Response("ok", { status: 200 });
}verifyWebhook() reads CLERK_WEBHOOK_SIGNING_SECRET, throws when it is missing, reads the raw body itself with request.text(), and refuses timestamps more than five minutes off, so call it before anything else touches the body. In Express, import it from @clerk/express/webhooks and use express.raw() on the route. The secret is on the endpoint's page, under Webhooks in the Clerk Dashboard.
The route must be public, as with Stripe. clerkMiddleware() protects no route by default; if you added a rule that does, exclude the webhook path and let the signature do the gatekeeping. If you copy a role from public_metadata into your database, a forged user.updated is a forged permission change.
| Detail | Stripe | Clerk (Svix) |
|---|---|---|
| Headers | Stripe-Signature (t=, v1=) | svix-id, svix-timestamp, svix-signature |
| Signed content | timestamp.body | id.timestamp.body |
| Signature | HMAC-SHA256, hex | HMAC-SHA256, base64, prefixed v1, |
| Secret | whsec_..., used as is | whsec_..., the base64 part after the prefix |
| Library call | stripe.webhooks.constructEvent() | verifyWebhook(), or wh.verify() from the svix package |
| Default tolerance | 5 minutes | 5 minutes |
| Same on every retry | event.id | the svix-id header |
Idempotency and replay: a valid event can arrive twice
A signature proves where an event came from, not whether you already handled it. In live mode, Stripe retries a failed delivery for up to three days, may deliver an event more than once, and promises no order. Its fulfillment guide warns that your fulfillment function might run several times, even concurrently, for the same Checkout Session. Clerk retries too, with the same svix-id on every attempt.
Two habits handle it. Record the id of each event you process, event.id for Stripe or the svix-id header for Clerk, and skip ids you have already seen, still answering 2xx so the retries stop. Then make the change itself run once, with a condition the database enforces:
-- One row per Stripe event you have handled
create table if not exists stripe_events (
id text primary key, -- evt_...
type text not null,
received_at timestamptz not null default now()
);
-- In the handler, inside one transaction:
insert into stripe_events (id, type) values ($1, $2)
on conflict (id) do nothing; -- 0 rows inserted: already handled, answer 200
update orders
set status = 'paid', paid_at = now()
where stripe_session_id = $3
and status = 'pending'; -- a second delivery finds nothing to changeStripe sometimes creates two separate events for the same change; to catch those, compare the id in data.object together with the event type. And keep the handler fast: return a 2xx before slow work and queue that work, or a timeout counts as a failed delivery and the event comes back.
Test that your webhook refuses unsigned events
Two requests tell you where you stand. Send them to your own endpoint, ideally a preview deployment in test mode, with ids that match nothing in your data.
- 1
List every webhook route
Search your code for
constructEvent,verifyWebhookandwebhook. Each route that receives events needs its own check, including old ones and Edge Functions outside the main app. - 2
Send an unsigned event
Post an event shaped like a real one, with no signature header, using the first command below, then read the status code in the table. An empty
{}body can mislead: a handler that readsevent.data.objectfirst crashes on it and answers 500, which looks like a refusal. - 3
Send a real signed event
With the Stripe CLI, forward events to the route and run
stripe trigger checkout.session.completed. It must answer 200: a route that refuses everything leaves paid orders unfulfilled. - 4
Check that nothing changed
Look for an order, a credit or a user created at the time of the unsigned request. If one exists, the handler acted before verifying, or without verifying.
# Your own endpoint only, ideally a preview deploy in test mode.
# The ids match nothing, and there is no Stripe-Signature header.
curl -i -X POST "https://YOUR-PREVIEW-DEPLOY/api/webhooks/stripe" \
-H "Content-Type: application/json" \
-d '{"id":"evt_selftest_unsigned","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test_selftest_nothing","object":"checkout.session","payment_status":"paid"}}}'
# Clerk: same idea, with no svix-* headers
curl -i -X POST "https://YOUR-PREVIEW-DEPLOY/api/webhooks/clerk" \
-H "Content-Type: application/json" \
-d '{"object":"event","type":"user.created","data":{"id":"user_selftest_nobody"}}'
# The signed path must still work (Stripe CLI, test mode)
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed| Answer | What it means |
|---|---|
| 400 with a signature error | The check ran and refused the event. This is the answer you want. |
| 2xx | The handler accepted an unsigned event, so anyone can send one. Fix this before anything else. |
| 500 | Inconclusive. The handler crashed, maybe on a missing secret, which is safe, maybe while processing the body, which is not. Read the server log. |
| 401, 403, 404 or a redirect | Something answered before your code: auth middleware, a wrong path, a login redirect. Real Stripe events are probably refused too, since Stripe counts a redirect as a failed delivery. |
The traps after the signature check is in
Verification that only runs when the secret is set
Stripe's snapshot example handler on its webhooks page only runs the check inside if (endpointSecret). Adapt it so the event falls back to the parsed body, as many generated handlers do with let event = req.body, and a missing environment variable in production means no verification at all. Fail closed instead: refuse to start, or refuse the request, as Stripe's Next.js and Express examples on GitHub do.
An empty secret
stripe-node only refuses an empty secret since version 22.6.2 (September 2026), and a whitespace-only one since 23.0.1. With an older version, or an SDK that does not check, an empty string becomes an HMAC key that anyone can use, which is the flaw in the advisory above. Run npm ls stripe, and confirm that STRIPE_WEBHOOK_SECRET is set in production and not only in .env.local.
Trusting the event body for the payment status
Stripe warns that a snapshot event can be stale by the time you process it, and its fulfillment guide retrieves the Checkout Session from the API and checks payment_status before delivering anything. With delayed methods such as bank debits, checkout.session.completed arrives before the money, which comes later with checkout.session.async_payment_succeeded. Act on what the API returns, not on the payload.
async function fulfillCheckout(sessionId: string) {
// Ask Stripe, with your secret key, instead of trusting the event body
const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["line_items"] });
if (session.payment_status === "unpaid") return; // delayed method: wait for async_payment_succeeded
// Mark the order paid once (the SQL above), then deliver session.line_items
}A success page that grants access
If the page at your success_url marks the order paid because the buyer landed on /success?session_id=..., the webhook is no longer the only door: anyone can load that URL. Stripe's guide does trigger fulfillment from the landing page, but through the same function that retrieves the session and checks it.
A secret that leaked
If a whsec_ value reached a repository, a frontend bundle or a log, roll it: in the Webhooks tab, open the endpoint, its overflow menu, then Roll secret. The old secret can expire at once or stay valid for up to 24 hours while you deploy. Then compare recent orders, credits and plan changes with real payments in Stripe. The leaked API key guide covers the rest of the cleanup.
Stripe also publishes the IP addresses its webhooks come from and recommends both protections. An allowlist is a good second layer, but only the signature proves the body was not changed.
What Keybrum tests on your webhook routes
Keybrum finds the routes in your app that look like webhooks and sends each one a single forged checkout.session.completed event, with no Stripe-Signature header and ids that match nothing, so it cannot confirm a real payment. A 2xx answer becomes a finding, with the exact request as proof; a 400 that names the signature is recorded as blocked. Clerk routes get one unsigned user.created event for a user that does not exist.
Your coding agent reads the finding and the fix through Keybrum's MCP server, and Keybrum attacks again after the fix, then on every deploy with a plan. It only attacks apps whose owner proved ownership. For what a full attack covers, see web application penetration testing.
Questions
Where do I find my Stripe webhook secret?
In Workbench, open the Webhooks tab, select the endpoint and reveal its signing secret, which starts with whsec_. For local testing, stripe listen prints its own secret in the terminal. Each endpoint has a different secret, and so does each mode, test or live.
Is the Stripe webhook secret the same as my secret key?
No. The secret key (sk_live_...) calls the Stripe API. The webhook signing secret (whsec_...) only lets your server check that an event came from Stripe. Both stay on the server, and a leak of either one calls for a rotation.
Why does Stripe webhook signature verification fail?
Stripe's troubleshooting guide names three causes: the wrong secret (CLI and Dashboard secrets differ, and so do test and live), a body that a framework parsed or rewrote before verification, or a malformed Stripe-Signature header. Fix the wrong input. Never remove the check to make the error go away.
How do I verify a Stripe webhook without the official library?
Split the Stripe-Signature header, keep t and every v1, and compute HMAC-SHA256 of t, a dot and the raw body, keyed with the endpoint secret. Compare the result with each v1 in constant time, then refuse the event if t is older than your tolerance. Stripe recommends its libraries, which already do all of this.
How do I verify Clerk webhooks?
Use verifyWebhook() from @clerk/nextjs/webhooks, or your framework's Clerk package, with CLERK_WEBHOOK_SIGNING_SECRET set, and answer 400 when it throws. Clerk sends webhooks through Svix, so the check uses the svix-id, svix-timestamp and svix-signature headers and the raw body.
What should I do if my webhook secret leaked?
Roll it from the endpoint's menu in the Webhooks tab, keeping the old one valid for up to 24 hours if you need time to deploy. Then compare recent orders, credits and plan changes with real payments in Stripe. If the secret sat in your frontend, check for other keys there with the API key leak checker.
Sources
- 1.Receive Stripe events in your webhook endpoint, Stripe Docs
- 2.Manage webhook endpoints: signature verification errors, Stripe Docs
- 3.How events work: thin and snapshot events, Stripe Docs
- 4.Fulfill orders, Stripe Docs
- 5.API keys, Stripe Docs
- 6.IP addresses: webhook notifications, Stripe Docs
- 7.stripe-node webhook signing examples (Next.js, Express), Stripe on GitHub
- 8.stripe-node pull request 2841: validate that webhook secrets are non-empty, Stripe on GitHub
- 9.stripe-node changelog (22.6.2 and 23.0.1), Stripe on GitHub
- 10.Sync Clerk data to your app with webhooks, Clerk Docs
- 11.Webhooks overview, Clerk Docs
- 12.verifyWebhook(), Clerk Docs
- 13.Verifying webhooks manually, Svix Docs
- 14.GHSA-xff3-5c9p-2mr4: Stripe webhook signature bypass via empty secret, GitHub Advisory Database
- 15.CVE-2026-41432, NIST National Vulnerability Database
- 16.Function configuration: skipping authorization checks, Supabase Docs
- 17.Handling Stripe webhooks with Edge Functions, Supabase Docs
- 18.route.js: webhooks, Next.js Docs