Stripe Webhook Security for Lovable Apps
AI-generated payment code rarely verifies Stripe webhook signatures. Without verification, anyone can POST a fake "payment.succeeded" event to your endpoint and trigger your fulfillment logic for free. Here's how to check, fix, and verify.
Stripe-Signature header. Your handler is supposed to verify that signature using the raw request body, the header, and your webhook signing secret. Most AI-generated webhook handlers skip this step entirely. They just parse the JSON and trust it. The result: anyone who finds your webhook URL can fake any event Stripe can send.
- What webhook signature verification actually does
- The specific failure pattern in Lovable-generated webhook handlers
- How to check if your handler verifies signatures
- How to implement signature verification properly
- Common pitfalls when fixing webhook verification
- Related Stripe issues to check after this is fixed
What webhook signature verification actually does
When Stripe sends you a webhook, it includes a header called Stripe-Signature. The header contains a timestamp and one or more HMAC SHA-256 signatures. The signatures are computed using your webhook signing secret (a value Stripe gives you when you create the webhook endpoint, separate from your API key) and the exact raw bytes of the request body.
To verify, you take the raw body, prepend the timestamp, compute the HMAC SHA-256 with your signing secret, and compare it to the signature in the header. If they match, the request really did come from Stripe and the body wasn't tampered with. If they don't match, you reject the request.
Stripe provides a helper for this in every official SDK: stripe.webhooks.constructEvent(). It does the comparison, throws if the signature is invalid, and returns a parsed event object if valid. The entire security model assumes you call this on every incoming webhook.
The specific failure pattern in Lovable-generated webhook handlers
The typical AI-generated webhook handler looks something like this:
// What Lovable / Bolt / V0 often generates
export default async function handler(req, res) {
const event = req.body; // already parsed JSON
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
// fulfill the order, send the email, update the database
await fulfillOrder(session.customer_email);
}
res.status(200).json({ received: true });
}
Three problems with this code:
- No signature check. Any HTTP request that reaches this endpoint with a JSON body in the right shape will trigger the fulfillment code.
- The body is already parsed. Even if you tried to verify the signature later, you can't — Stripe's verification requires the raw bytes, and once a framework parses the JSON, the original bytes are gone.
- No idempotency. If the same event is sent twice (which Stripe does for retries), fulfillment runs twice.
An attacker who finds this endpoint URL just needs to POST a JSON body that looks like a real Stripe event:
POST /api/stripe-webhook HTTP/1.1
Content-Type: application/json
{
"type": "checkout.session.completed",
"data": {
"object": {
"customer_email": "attacker@example.com",
"amount_total": 100000,
"metadata": { "product_id": "premium-plan" }
}
}
}
That request, with no signature header, triggers fulfillOrder() and the attacker gets the product for free. If your fulfillment includes things like granting subscription access, sending API keys, or unlocking downloads, the financial damage scales with whatever the product is worth.
How to check if your handler verifies signatures
Three quick checks.
Check 1: Read the code
Open your webhook handler file (typically api/stripe-webhook.js, api/webhooks/stripe.js, or a Supabase Edge Function). Search the file for any of these strings:
constructEventstripe.webhooksStripe-Signatureorstripe-signature
If none of those appear, the handler is not verifying signatures. If only some appear (for example, you read the signature header but never call constructEvent), the verification is incomplete and may not actually run.
Check 2: Send a fake request
From the command line, POST a fake Stripe event to your webhook URL with no signature header:
curl -X POST https://yourapp.com/api/stripe-webhook \
-H "Content-Type: application/json" \
-d '{"type":"checkout.session.completed","data":{"object":{"id":"test"}}}'
If you get a 200 OK back, your handler accepted the unsigned request. That is the bug. A correctly-verifying handler should return 400 or 401 because the signature is missing.
Don't actually trigger fulfillment in a test. Either run this against a staging endpoint, or temporarily modify the type to something your handler doesn't act on.
Check 3: Confirm raw body access
Even if your code calls constructEvent, it has to pass the raw bytes, not the parsed JSON. In Next.js API routes you need export const config = { api: { bodyParser: false } }. In Express you need a special express.raw() middleware on that route. In Vercel/Edge functions you need to read the body as a string before any parsing happens. If your framework auto-parses JSON and you're passing the parsed object to constructEvent, the verification will fail silently or throw, and many AI-generated handlers fall back to "trust it anyway" in the catch block.
How to implement signature verification properly
Here's a working pattern for a Vercel serverless function (Node runtime). Adapt to your framework as needed.
Step 1: Get your webhook signing secret
Go to dashboard.stripe.com/webhooks, click on your webhook endpoint, and reveal the signing secret. It starts with whsec_. Add it to your environment variables as STRIPE_WEBHOOK_SECRET. This is different from your API secret key.
Step 2: Disable body parsing on the route
Stripe's signature verification needs the raw bytes of the request body. If your framework auto-parses JSON, you have to opt out for this route.
// pages/api/stripe-webhook.js or app/api/stripe-webhook/route.js
export const config = {
api: {
bodyParser: false,
},
};
Step 3: Read the raw body and verify
import Stripe from 'stripe';
import { buffer } from 'micro';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).end();
}
const sig = req.headers['stripe-signature'];
const rawBody = await buffer(req);
let event;
try {
event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Now event is verified. Handle it.
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
await fulfillOrder(session);
}
res.status(200).json({ received: true });
}
The buffer() helper from micro reads the raw request body as a Buffer. The constructEvent() call verifies the signature using the raw bytes, the header, and your signing secret. If anything is wrong, it throws and you return 400. Stripe will retry on a non-2xx response, so this is the correct way to reject invalid requests.
Step 4: Add idempotency
Stripe will retry webhook deliveries if your handler returns an error or times out. Even with verification working, you can receive the same event twice. To avoid fulfilling an order twice, store the event ID and skip processing if you've already seen it.
// Inside your handler, after verification
const alreadyProcessed = await db.events.findOne({ stripe_event_id: event.id });
if (alreadyProcessed) {
return res.status(200).json({ received: true, deduplicated: true });
}
// Process the event
await fulfillOrder(event.data.object);
await db.events.insertOne({ stripe_event_id: event.id, processed_at: new Date() });
Common pitfalls when fixing webhook verification
Pitfall 1: Forgetting to disable the body parser
If your framework auto-parses JSON and you pass the parsed object to constructEvent(), verification will fail because the JSON serialization isn't byte-identical to the original request body. Make sure body parsing is off on the webhook route specifically.
Pitfall 2: Verifying with the wrong secret
The webhook signing secret (whsec_...) is different from your Stripe API secret key (sk_...). They are two different secrets. Verification will fail if you use the API key instead of the webhook secret.
Pitfall 3: Different secrets for test mode and live mode
Stripe has separate webhook endpoints and separate signing secrets for test mode and live mode. If your code uses one secret in both environments, verification will fail in whichever mode doesn't match the stored secret. Use environment variables and confirm the right secret is loaded per environment.
Pitfall 4: Silently swallowing verification errors
Some AI-generated handlers wrap constructEvent in a try-catch and fall back to using the raw body if verification fails. This defeats the entire purpose of verification. The catch block should reject the request with 400, not continue processing. Audit any try-catch around verification carefully.
Pitfall 5: Trusting the event payload for sensitive fields
Even after a valid signature, the data inside the event is whatever you sent to Stripe when creating the session. If you put untrusted user input in metadata and then trust that metadata in your fulfillment logic, you have a different injection problem. Always re-verify critical fields (amount, customer, product) against Stripe directly using the API before fulfilling.
Related Stripe issues to check after this is fixed
Signature verification fixes the most exploitable webhook vulnerability, but it isn't the only Stripe-related issue in AI-built apps.
Stripe secret keys in the client bundle
Run a search through your deployed JavaScript bundle for sk_live_ and sk_test_. If anything turns up, you have a secret key leak. The Stripe secret key should only ever live on the server. The publishable key (pk_live_, pk_test_) is the one that ships to the client.
Insufficient permissions on fulfillment endpoints
Beyond the webhook, your app likely has endpoints like "grant me access to the paid feature" that the client calls after payment. These need their own auth checks — confirming via Stripe API that the user actually paid, not just trusting a flag in the client-side state.
Test mode keys in production
If your production deployment has sk_test_ or pk_test_ environment variables instead of sk_live_ and pk_live_, you're running test mode in production. Charges won't actually clear. Audit your production environment variables.
The TL;DR
- Stripe webhooks need signature verification or anyone can fake them
- AI builders rarely include verification by default
- To fix: disable body parsing on the route, read the raw body, call
stripe.webhooks.constructEvent(rawBody, signatureHeader, webhookSecret) - Reject any request that fails verification with a 400
- Add idempotency (store event ID, skip duplicates) so retries don't double-fulfill
- Test by POSTing an unsigned fake event to your endpoint. A correct handler returns 400.
- Verification is necessary but not sufficient. Also check for leaked secret keys and confirm test mode keys aren't in production.
The other failure mode that costs founders the most. If you have user data in Supabase, this guide is the higher priority of the two.
If your app has any AI features, you also need rate limiting. One user with a loop script can burn $5,000 in OpenAI credits overnight.
The pillar piece explaining the meta-pattern: why AI-built apps consistently ship with the same five production gaps, and what to do about it.
Want this done for you?
If you'd rather not refactor your webhook handler and audit every Stripe touchpoint, Rivetz does it for you. The audit ($1,000, 3 business days) finds every issue. The cleanup ($3,500, 14 business days) fixes all CRITICAL and HIGH severity findings including webhook security, secrets, RLS, and rate limits.
100% async. No calls. No scope creep. Fixed price.
See offers See full checklist