Stripe webhook signature verification in AI-generated apps: the check that stops free orders
Short answer
Stripe signs every webhook event with an HMAC-SHA256 signature in the Stripe-Signature header, using the endpoint’s whsec_ signing secret. Your handler must verify that signature against the raw, unmodified request body using Stripe’s library (constructEvent) before acting on the event; without verification, anyone can send a fake payment_intent.succeeded and get goods or access for free. Stripe’s libraries reject events older than a 5-minute default tolerance to stop replays — never set the tolerance to 0 — and handlers should deduplicate by event ID because Stripe may deliver an event more than once.
Last updated · Sekrd Research
- Stripe signs events with HMAC-SHA256; the header is
Stripe-Signaturewith at=timestamp andv1=signature. - Each endpoint has its own
whsec_signing secret; test and live secrets differ. - Verification requires the raw request body — any parsing or re-serialization breaks it.
- Stripe’s libraries default to a 5-minute timestamp tolerance; a tolerance of 0 disables the recency check.
- Sekrd dataset (799 hosts): payment webhooks accepting unsigned requests found on multiple hosts (Stripe and Lemon Squeezy).
What goes wrong without verification
A webhook endpoint is a public URL. If it trusts the JSON it receives, an attacker can post a crafted checkout.session.completed or payment_intent.succeeded event and your app will fulfil the order, unlock the subscription or credit the account. Stripe’s documentation names exactly this risk: fake events triggering fulfilment, account access or record changes.
Why AI-generated handlers break verification
- The body is parsed before verification. Frameworks parse JSON by default; Stripe needs the raw bytes. The signature fails, and the “fix” an assistant suggests is removing the check.
- The secret is missing in production, and the code skips verification when it is unset — a pattern that also appears in samples written for local testing.
- The wrong secret is used (test vs live, or the API key instead of
whsec_).
A correct handler (Next.js route)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body — do not use req.json()
const sig = req.headers.get("stripe-signature");
const secret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
if (!sig || !secret) return new Response("missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, secret); // 5-minute tolerance by default
} catch {
return new Response("invalid signature", { status: 400 });
}
if (await alreadyProcessed(event.id)) return new Response("ok"); // dedupe retries
// ...fulfil the order...
return new Response("ok");
}
Checklist
- Verify every event with the library, using the raw body and the endpoint’s
whsec_secret. - Fail closed: if the secret is not configured, reject the request — never skip verification.
- Keep the default tolerance; never set it to 0.
- Deduplicate by event ID; Stripe retries for up to three days in live mode and may deliver duplicates.
- Return 2xx quickly and do slow work asynchronously.
- Optionally also allowlist Stripe’s published webhook IP addresses.
- Roll the signing secret if it may have leaked; Stripe lets you keep the old one valid for up to 24 hours during the switch.
The same rules apply to Lemon Squeezy, Paddle and other providers: each signs its webhooks, and each signature must be checked on the raw body.
Frequently asked questions
Why does Stripe webhook signature verification fail?
Most often because the request body was parsed or modified before verification. Stripe computes the signature over the raw bytes, so use the raw body (for example req.text() in Next.js route handlers) and the endpoint’s whsec_ secret for the matching mode (test or live).
What is the default Stripe webhook timestamp tolerance?
Stripe’s official libraries default to a 5-minute tolerance between the signed timestamp and the current time. Setting the tolerance to 0 disables the recency check and should be avoided.
What happens if I don’t verify Stripe webhooks?
Anyone can send fake events to your endpoint, such as a payment succeeded event, and trigger fulfilment, account upgrades or record changes without paying.
Sources
Don't ship until you're sekrd
Run a free scan to find the vulnerabilities your AI missed.
Scan Your App Free