How to Avoid: Access Granted Before Payment Confirmed
July 18, 2026 · 7-minute read · Fairy
The short answer
To prevent access granted before payment confirmed, never trigger fulfillment on PaymentIntent.create. Instead, use Stripe webhooks to listen for payment_intent.succeeded or checkout.session.completed events, verify webhook signatures with constructEvent(), and implement idempotency checks using event.id before granting access.
The Direct Answer: Use Webhooks, Not Create Responses
To prevent access granted before payment confirmed, never trigger fulfillment based on the response from PaymentIntent.create() or checkout.sessions.create(). These API calls initiate payment attempts—they don't confirm payment success. Instead, implement a webhook handler that listens for payment_intent.succeeded or checkout.session.completed, verifies the webhook signature, checks for duplicate events, and only then grants access.
This failure mode is one of the most common critical bugs in AI-generated payment code, and it results in users receiving products, subscriptions, or access without actually paying.
What This Failure Mode Looks Like
Here's the pattern AI frequently generates:
// DANGEROUS: AI-generated code that grants access too early
app.post('/api/subscribe', async (req, res) => {
const { priceId, userId } = req.body;
const paymentIntent = await stripe.paymentIntents.create({
amount: 2000,
currency: 'usd',
customer: userId,
});
// BUG: Access granted immediately after create, not after success
await db.users.update(userId, { subscriptionActive: true });
await sendWelcomeEmail(userId);
res.json({ clientSecret: paymentIntent.client_secret });
});
The code looks reasonable. It creates a PaymentIntent, updates the user's subscription status, sends a welcome email, and returns the client secret. The problem? PaymentIntent.create() only creates an intent to collect payment. The customer hasn't entered their card yet. They might enter invalid details, have insufficient funds, or simply close the browser.
But your database already says they're subscribed. They already received the welcome email. If you're providing digital access—SaaS features, downloadable content, API keys—they already have it.
Why AI Generates This Bug
AI code generators produce this pattern for several interconnected reasons:
Optimizing for the Happy Path
Training data skews toward tutorials and examples that demonstrate the simplest working flow. Many Stripe tutorials show synchronous request-response patterns because they're easier to explain. AI models learn that "create payment → do fulfillment" is the pattern, missing the critical asynchronous confirmation step.
Conflating Intent with Completion
The naming is genuinely confusing. A PaymentIntent sounds like a payment, and the successful API response feels like success. AI models don't have the domain knowledge to distinguish between "we successfully asked for a payment" and "the payment succeeded."
Avoiding Webhook Complexity
Webhooks require additional infrastructure: a publicly accessible endpoint, signature verification, idempotency handling, and state management. AI generates the simpler synchronous pattern because it requires less code and fewer concepts.
Missing Adversarial Thinking
AI doesn't naturally consider what happens when users don't complete flows, when payments fail, or when attackers forge requests. It generates code for the expected case, not the edge cases that actually break payment systems.
How to Detect This in Your Codebase
Look for these signals in AI-generated payment code:
Red flags in the create-response handler:
- Database writes (
update,insert,create) that modify user state - Function calls like
grantAccess(),fulfill(),activate(),sendEmail() - Setting boolean flags like
isPaid,isActive,hasAccessto true - Logging "payment successful" or similar messages
Red flags in your webhook handler (or lack thereof):
- No webhook endpoint exists at all
- Webhook processes
req.bodydirectly without signature verification - No check for
payment_intent.succeededorcheckout.session.completedevents - No idempotency check on
event.id
Here's a quick audit checklist:
// Search your codebase for these patterns:
// 1. Find all PaymentIntent.create calls
// Check: Does ANYTHING happen after this besides returning clientSecret?
// 2. Find your webhook endpoint
// Check: Is constructEvent() called with rawBody and signature?
// 3. Find your fulfillment logic
// Check: Is it inside a handler for 'payment_intent.succeeded'?
// 4. Check for idempotency
// Check: Is event.id stored and checked before processing?
The Correct Pattern
Here's the secure, production-ready implementation:
Step 1: Create Payment Without Fulfillment
app.post('/api/create-checkout', async (req, res) => {
const { priceId, userId } = req.body;
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${YOUR_DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${YOUR_DOMAIN}/canceled`,
metadata: { userId }, // Pass userId for fulfillment
});
// DO NOT grant access here
// Just return the session URL
res.json({ url: session.url });
});
Step 2: Set Up Webhook Handler with Signature Verification
// Use express.raw() for this route - you need the raw body for verification
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
// CRITICAL: Verify webhook signature
try {
event = stripe.webhooks.constructEvent(
req.body, // Must be raw body, not parsed JSON
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the verified event
await handleStripeEvent(event);
res.json({ received: true });
});
Step 3: Implement Idempotent Event Handling
async function handleStripeEvent(event) {
// CRITICAL: Check if we've already processed this event
const existingEvent = await db.webhookEvents.findOne({ eventId: event.id });
if (existingEvent) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Mark as processing before handling (prevents race conditions)
await db.webhookEvents.insert({
eventId: event.id,
type: event.type,
processedAt: new Date()
});
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object);
break;
case 'payment_intent.succeeded':
await handlePaymentSucceeded(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object);
break;
}
}
Step 4: Fulfill Only After Confirmed Payment
async function handleCheckoutComplete(session) {
const userId = session.metadata.userId;
// NOW it's safe to grant access
await db.users.update(userId, {
subscriptionActive: true,
stripeCustomerId: session.customer,
subscriptionId: session.subscription
});
await sendWelcomeEmail(userId);
console.log(`Access granted to user ${userId} after confirmed payment`);
}
Related Vulnerabilities to Check
When you're auditing AI-generated payment code, this failure mode rarely appears alone. Check for these related issues:
Missing Webhook Signature Verification
If your webhook processes events without calling constructEvent(), attackers can forge events. They can POST fake payment_intent.succeeded events to grant themselves access, trigger fake refunds, or manipulate subscription states.
// VULNERABLE: No signature verification
app.post('/webhooks/stripe', async (req, res) => {
const event = req.body; // Anyone can send fake events
await handleStripeEvent(event);
});
Missing Idempotency Checks
Stripe retries webhooks when your endpoint times out or returns 5xx. Without deduplication on event.id, the same successful payment triggers multiple fulfillments—double charges in some architectures, or duplicate shipments, or multiple license grants.
Using Parsed JSON Instead of Raw Body
A subtle but critical mistake: constructEvent() needs the raw request body exactly as Stripe sent it. If your middleware parses JSON first, the signature won't match:
// BROKEN: JSON parsing breaks signature verification
app.use(express.json()); // This parses ALL routes
app.post('/webhooks/stripe', async (req, res) => {
// req.body is now parsed JSON, not raw string
// constructEvent() will fail
});
The fix is to use express.raw() specifically for the webhook route, before the general JSON middleware.
Testing Your Implementation
Before shipping payment code to production:
-
Test webhook signature verification by sending a POST request with a fake payload. Your endpoint should reject it with a 400 error.
-
Test idempotency by replaying the same webhook event twice. Your handler should process it once and skip the duplicate.
-
Test the failure path by creating a PaymentIntent but never completing the payment. Verify no access is granted.
-
Use Stripe CLI to forward webhooks to your local development environment:
stripe listen --forward-to localhost:3000/webhooks/stripe -
Check Stripe Dashboard under Developers → Webhooks → Recent Events to see delivery attempts and responses.
Why This Matters Beyond Lost Revenue
This isn't just about users getting free access. The implications compound:
- Inventory and capacity: If you're selling limited items or seats, premature fulfillment depletes inventory for non-paying "customers"
- Legal exposure: For regulated products (software licenses, financial services), granting access without confirmed payment may violate compliance requirements
- Fraud amplification: Attackers can script payment initiation without completion, draining trial credits or testing stolen cards while getting full access
- Accounting chaos: Your revenue recognition doesn't match actual payments, complicating audits and financial reporting
Getting AI-Generated Payment Code Reviewed
Payment code is high-stakes—bugs translate directly to financial loss or security exposure. This is precisely where AI-generated code needs expert verification before production.
Fairy's code review service specifically flags payment flow issues like premature fulfillment, missing webhook verification, and idempotency gaps. For a quick check on pull requests containing payment logic, Fairy Scout can identify these patterns before they merge.
The core principle holds across all payment integrations: AI does the initial work, but webhook-based confirmation, signature verification, and idempotency handling require explicit human verification. These aren't patterns AI reliably produces on its own.
Frequently asked questions
Why does AI-generated code grant access before payment confirms?
AI models optimize for the happy path and often conflate payment initiation with payment completion. When generating payment flows, they frequently trigger fulfillment immediately after PaymentIntent.create returns, not realizing this only means the payment attempt started, not that funds were captured.
What Stripe event should trigger access or fulfillment?
Use payment_intent.succeeded for direct payments or checkout.session.completed for Checkout Sessions. These events fire only after Stripe confirms the payment was successful and funds are secured.
How do I verify Stripe webhook signatures?
Call stripe.webhooks.constructEvent(rawBody, req.headers['stripe-signature'], webhookSecret) before processing any event. Use the raw request body, not parsed JSON. This cryptographically verifies the event came from Stripe.
What is webhook idempotency and why does it matter?
Stripe retries webhooks on timeouts or 5xx responses. Without idempotency checks, the same event processes multiple times, causing duplicate fulfillment. Store processed event.id values and skip events you've already handled.
Can attackers exploit missing webhook verification?
Yes. Without signature verification, anyone can POST fake webhook events to your endpoint, forging subscription grants, refunds, or fulfillment triggers. This is a critical security vulnerability.
Have AI-generated work you’d want verified? Connect with a Fairy → or run a free check with Scout.
More resources