Webhook signature verification: the free-money bug
If your payment webhook route doesn't verify who actually sent the request, anyone can POST a fake "payment succeeded" event and unlock a paid plan for free.
Why this is invisible in a demo
Every payment integration looks identical whether or not signature verification exists: you click checkout, complete a test payment, and your account upgrades. The webhook route fires, reads the event, updates the database. It works. What's missing only shows up when someone who never paid sends the exact same request body directly to your public webhook URL — and your route has no way to tell the difference between that and a real event from your payment provider.
The webhook route parses the JSON body, checks event.type, and updates the user's plan — with nothing confirming the request actually came from the payment provider.
The route reads the raw request body, recomputes the expected signature using your webhook secret, and rejects the request outright if it doesn't match — before any business logic runs.
The fix (Stripe example — same shape for any provider)
The critical detail: verification needs the raw, unparsed request body. If your framework or a middleware already parsed it into JSON before your handler runs, the signature check will fail against valid events too — this is the most common reason people give up and skip verification rather than fix the actual issue.
// app/api/webhooks/stripe/route.ts
// Vulnerable — trusts the parsed body with no verification
export async function POST(req: Request) {
const event = await req.json();
if (event.type === 'checkout.session.completed') {
await upgradeUserPlan(event.data.object.customer);
}
return new Response('ok');
}
// Fixed — verifies the raw payload's signature before trusting anything
export async function POST(req: Request) {
const rawBody = await req.text(); // raw text, NOT req.json() — signature needs the exact bytes
const signature = req.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature!,
process.env.STRIPE_WEBHOOK_SECRET! // server-only secret, never NEXT_PUBLIC_
);
} catch {
return new Response('Invalid signature', { status: 400 }); // reject anything that fails
}
if (event.type === 'checkout.session.completed') {
await upgradeUserPlan(event.data.object.customer);
}
return new Response('ok');
}Cashfree, Razorpay, and Lemon Squeezy each ship the same shape — a raw-body HMAC check against a webhook secret, with their own SDK method and header name in place of Stripe's. If you're using more than one provider (a common pattern when routing checkout by region), each one needs its own verification, not a shared assumption that one provider's check covers the others.
Want to know if your payment webhook actually verifies anything?
StackSecured checks whether your webhook routes trust an unverified request body — the difference between a real security boundary and a checkbox that does nothing.
Run a free scanCommon questions
What actually happens if I skip webhook signature verification?
+
Your webhook URL is a public endpoint — anyone who finds it can POST a request body shaped like a real "payment succeeded" event, and if your route does not verify who actually sent it, your code upgrades that account exactly as if a real payment happened. No card gets charged, no money moves, and your database says the user paid. It is the same class of bug as trusting a client-side "isPaid: true" flag, just one layer further from the browser.
Why do AI coding tools skip this so often?
+
Ask Cursor, Bolt, or Lovable to "add Stripe checkout" and the generated route usually does exactly two things: parse the incoming JSON, and update the user's plan based on the event type. That is enough to make the happy path work end-to-end in a demo — a real Stripe test event arrives, the plan updates, everyone moves on. Verifying the signature is a separate step that requires reading the raw request body before any JSON parsing happens, which nothing about "make checkout work" naturally prompts the tool to add.
Does this apply to Cashfree, Razorpay, and Lemon Squeezy too, or just Stripe?
+
Every payment provider with a webhook ships the same mechanism for the same reason — a signature (or HMAC hash) computed from the raw payload and a secret only you and the provider know, sent as a header alongside the event. Stripe calls it Stripe-Signature, Cashfree and Razorpay ship their own equivalent headers. The verification logic differs slightly per SDK, but the requirement — verify before you trust — is universal to every webhook-driven payment integration, not a Stripe-specific quirk.
How do I test that verification is actually working, without a real payment?
+
Every major provider ships a CLI or dashboard tool to send signed test events to your endpoint — Stripe CLI's `stripe trigger`, for example. Send a real signed test event first and confirm it succeeds, then send a request with the signature header stripped or altered and confirm your route rejects it with a 400. If the second request also succeeds, verification is not actually running, regardless of what the code appears to do.
More guides: IDOR: the sequential-ID bug · Exposed API keys in AI-built apps · Full vibe-coding security checklist