The 3 Most Common Vulnerabilities in Stripe Webhook Implementations
Missing signature verification and non-idempotent event handling are the easiest ways for attackers to spoof payments in your SaaS.
"We just look for `checkout.session.completed` in the request body and upgrade the user."
This is how millions of dollars in SaaS revenue are stolen silently every year.
Integrating Stripe seems easy. You create a checkout session, redirect the user, and wait for a webhook to tell your server the payment succeeded. However, webhooks are just HTTP requests. If you don't secure them properly, an attacker can simply send a fake HTTP request to your webhook endpoint and grant themselves a lifetime premium subscription for free.
1. Missing Signature Verification
The most devastating mistake developers make is trusting the payload blindly. A webhook endpoint is a public URL. Anyone on the internet can send a POST request to it.
export async function POST(req) {
const event = await req.json();
// Attackers can fake this payload!
if (event.type === 'checkout.session.completed') {
const userId = event.data.object.client_reference_id;
await upgradeUserToPremium(userId);
}
return new Response('OK', { status: 200 });
}
To fix this, you must construct the event using Stripe's official SDK and the `stripe-signature` header. Stripe signs every webhook using a secret key (whsec_...) known only to your server and Stripe. If the signature doesn't match the payload, the SDK throws an error, immediately stopping the spoofed request.
2. Timing & Replay Attacks
Even if you verify signatures, you might be vulnerable to Replay Attacks if you don't verify the timestamp. An attacker could intercept a valid webhook payload and its signature (e.g., from a man-in-the-middle attack or leaked logs) and repeatedly resend it to your server to grant multiple credits or extensions.
The Stripe SDK automatically checks the timestamp in the `stripe-signature` header by default, rejecting payloads older than 5 minutes. However, developers sometimes disable this tolerance during testing and forget to re-enable it in production.
3. Non-Idempotent Handlers
Stripe clearly states in their documentation: Webhooks may be sent more than once.
If your webhook handler adds $50 in credits to a user's account whenever `payment_intent.succeeded` fires, what happens if Stripe's systems experience a network blip and retry the webhook 3 times? The user gets $150 in credits for a $50 payment.
Always store the Stripe `event.id` in your database. Before processing a webhook, check if you have already processed that specific event ID. If you have, return a 200 OK immediately without modifying any business logic.
4. The Correct Implementation
Here is what a secure, idempotent Stripe Webhook handler looks like in Next.js:
const body = await req.text();
const sig = req.headers.get('stripe-signature');
let event;
try {
// 1. Verify Signature & Timestamp
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// 2. Enforce Idempotency
const alreadyProcessed = await db.processedEvents.findUnique({ where: { id: event.id } });
if (alreadyProcessed) {
return new Response('Already processed', { status: 200 });
}
// ... Handle business logic ...
// 3. Save Event ID to prevent replays
await db.processedEvents.create({ data: { id: event.id } });
return new Response('OK', { status: 200 });
}
5. Final Thoughts
Fintech integrations leave zero room for error. A flawed UI component might annoy a user, but a flawed billing architecture will drain your bank account. Always verify signatures, always enforce idempotency, and never trust public endpoints.
Note on Organization Security:We never require full GitHub/GitLab organization admin access. A read-only invite to the specific repository and branch being audited is all that is required, keeping your broader infrastructure completely isolated.
Not sure which methodology fits your application?
We'll analyze your stack, endpoints, and repo requirements in a quick 10-minute triage.