Stripe webhook secret: how to verify every event and refuse fakes

The Stripe webhook secret is the whsec_ signing secret Stripe creates for each webhook endpoint. Stripe signs every event with it, as an HMAC-SHA256 of a timestamp and the raw body, and your server must check that signature with constructEvent() before acting. It stays on the server. Without the check, anyone who finds the URL can post a fake checkout.session.completed event.

The team behind Keybrum's attacksUpdated 15 min read

In short

  • Each webhook endpoint has its own whsec_ secret, a different one in test and live mode, and stripe listen prints another for local work. A signature error usually means the wrong one.
  • Verify against the raw body. express.json() before the route, or req.json() in Next.js, changes the bytes: use express.raw() or await 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.id and 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

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:

ValueStarts withWhat it doesWhere it belongs
Publishable keypk_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 keysk_live_, sk_test_Calls every Stripe API with no limit: charges, refunds, customers.Server only
Restricted keyrk_live_, rk_test_Calls only the APIs you allowed.Server only
Webhook signing secretwhsec_Lets your server check that an event really came from Stripe.Server only
Stripe's docs say only publishable keys are safe outside your back end. A whsec_ value in your frontend bundle is a leak: see API keys exposed in the frontend.

Where to find your Stripe webhook secret

  1. 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. 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. 3

    Local development: the Stripe CLI

    stripe listen --forward-to localhost:3000/api/webhooks/stripe prints a line like Your 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. 4

    Store it on the server

    Put it in a server environment variable such as STRIPE_WEBHOOK_SECRET. Never give it a NEXT_PUBLIC_ or VITE_ 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 saysWhat 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.updatedUpgrades a plan or extends a subscription nobody paid for.
payment_intent.succeeded with a user id in metadataCredits a balance, or AI credits that cost you real money on your provider's bill.
customer.subscription.deletedCuts off a real customer's access.
Clerk user.created, user.updated or user.deletedCreates, 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):

text
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 one v1 per 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

ts
// 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 });
}
Fails closed: no secret, no header or a bad signature all end before the event is read.

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:

js
// 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:

js
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.

ts
// 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.

ts
// 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.

DetailStripeClerk (Svix)
HeadersStripe-Signature (t=, v1=)svix-id, svix-timestamp, svix-signature
Signed contenttimestamp.bodyid.timestamp.body
SignatureHMAC-SHA256, hexHMAC-SHA256, base64, prefixed v1,
Secretwhsec_..., used as iswhsec_..., the base64 part after the prefix
Library callstripe.webhooks.constructEvent()verifyWebhook(), or wh.verify() from the svix package
Default tolerance5 minutes5 minutes
Same on every retryevent.idthe 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:

sql
-- 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 change
If the insert reports zero rows, stop and answer 200. Run both statements in one transaction, so an event that fails halfway is not recorded as handled.

Stripe 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. 1

    List every webhook route

    Search your code for constructEvent, verifyWebhook and webhook. Each route that receives events needs its own check, including old ones and Edge Functions outside the main app.

  2. 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 reads event.data.object first crashes on it and answers 500, which looks like a refusal.

  3. 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. 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.

bash
# 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
Run it against your own app only. The fake ids cannot match a real payment or user.
AnswerWhat it means
400 with a signature errorThe check ran and refused the event. This is the answer you want.
2xxThe handler accepted an unsigned event, so anyone can send one. Fix this before anything else.
500Inconclusive. 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 redirectSomething 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.

ts
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. 1.Receive Stripe events in your webhook endpoint, Stripe Docs
  2. 2.Manage webhook endpoints: signature verification errors, Stripe Docs
  3. 3.How events work: thin and snapshot events, Stripe Docs
  4. 4.Fulfill orders, Stripe Docs
  5. 5.API keys, Stripe Docs
  6. 6.IP addresses: webhook notifications, Stripe Docs
  7. 7.stripe-node webhook signing examples (Next.js, Express), Stripe on GitHub
  8. 8.stripe-node pull request 2841: validate that webhook secrets are non-empty, Stripe on GitHub
  9. 9.stripe-node changelog (22.6.2 and 23.0.1), Stripe on GitHub
  10. 10.Sync Clerk data to your app with webhooks, Clerk Docs
  11. 11.Webhooks overview, Clerk Docs
  12. 12.verifyWebhook(), Clerk Docs
  13. 13.Verifying webhooks manually, Svix Docs
  14. 14.GHSA-xff3-5c9p-2mr4: Stripe webhook signature bypass via empty secret, GitHub Advisory Database
  15. 15.CVE-2026-41432, NIST National Vulnerability Database
  16. 16.Function configuration: skipping authorization checks, Supabase Docs
  17. 17.Handling Stripe webhooks with Edge Functions, Supabase Docs
  18. 18.route.js: webhooks, Next.js Docs

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub