Back to Learn

DEEP DIVE

Stripe is safe.
Your backend isn't.

Simon Schubert

Simon Schubert

Security Engineer · 6 min read

Every week I talk to a vibe coder who has just shipped a paid product and feels good about it. They used Stripe. They followed the docs. The checkout flow works. And they genuinely believe that means their payment stack is secure.

It isn't. Stripe protects one thing: your customers' card data. The moment money moves from their card to your Stripe account, Stripe's job is done. What happens next — the webhook that fires, the fulfillment your server triggers, the database row that gets updated — that's entirely your code. And in most vibe-coded apps, that code has never been reviewed by anyone who's broken into a payment system before.

I spent years on the offensive side of that equation. Here are the four places I look first.

1. Unverified webhooks

Stripe fires a webhook to your server every time something happens — a payment succeeds, a subscription renews, a refund is issued. Your server listens for these events and acts on them: unlocking features, sending receipts, updating user records.

The problem is that anyone on the internet can POST to your webhook endpoint. If you're not verifying the request actually came from Stripe, an attacker can replay an old payment_intent.succeeded event — or fabricate a new one — and your server will process it as legitimate.

⚠️
I've seen apps where a POST to /api/webhook with a fake payment body immediately upgraded an account to the paid plan. No card needed.

The fix is one function call. Stripe gives you a signing secret and an SDK method to verify it:

❌ vulnerable
// Processes any POST, from anyone
export async function POST(req: Request) {
  const event = await req.json();

  if (event.type === 'payment_intent.succeeded') {
    await unlockUserAccess(event.data.object.metadata.userId);
  }
}
✓ correct
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response('Webhook signature verification failed', { status: 400 });
  }

  if (event.type === 'payment_intent.succeeded') {
    await unlockUserAccess(event.data.object.metadata.userId);
  }
}

2. Missing idempotency keys

Networks fail. Users double-click. Servers retry. Without idempotency keys, a single purchase can be processed multiple times — and depending on your logic, that can mean double charges, double fulfillment, or both.

Stripe supports idempotency keys on all write operations. Pass one and Stripe will deduplicate for you — the same key will always return the same result, even if the request is sent ten times.

✓ correct
import { randomUUID } from 'crypto';

const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 2000,
    currency: 'usd',
    customer: customerId,
  },
  {
    idempotencyKey: `pi-${userId}-${orderId}-${randomUUID()}`,
  }
);
Scope your idempotency key to the specific operation and user. Using just an order ID means a retry from a different user could reuse the same key if your ID generation is weak.

3. Price manipulation

This one is subtle and crushingly common in AI-generated code. Here's the pattern: your frontend sends the product ID and the price to your server, your server passes that price directly to Stripe when creating the payment intent.

❌ vulnerable
// Don't do this. The client controls the amount.
export async function POST(req: Request) {
  const { productId, amount, userId } = await req.json();

  const paymentIntent = await stripe.paymentIntents.create({
    amount,  // attacker sends amount: 1
    currency: 'usd',
    metadata: { userId, productId },
  });
}

An attacker intercepts the request, changes the amount to 1 cent, and your server dutifully charges them 1 cent and sends a payment_intent.succeeded event. Your webhook fires, sees a success, and unlocks whatever they bought.

✓ correct
const PRICES: Record<string, number> = {
  'plan_pro': 4900,
  'plan_starter': 1900,
};

export async function POST(req: Request) {
  const { productId, userId } = await req.json();

  // Amount comes from your server, never the client
  const amount = PRICES[productId];
  if (!amount) return new Response('Invalid product', { status: 400 });

  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency: 'usd',
    metadata: { userId, productId },
  });
}

4. Refund and cancellation logic

Refund endpoints are often thrown together quickly and rarely tested beyond the happy path. The two failures I see most often: no authentication check (meaning anyone can refund any charge ID they find), and no ownership verification (meaning an authenticated user can refund a charge that belongs to a different user).

❌ vulnerable
// Any authenticated user can refund any charge
export async function POST(req: Request) {
  const { chargeId } = await req.json();
  const refund = await stripe.refunds.create({ charge: chargeId });
  return Response.json({ refund });
}
✓ correct
export async function POST(req: Request) {
  const session = await getSession(req);
  if (!session) return new Response('Unauthorized', { status: 401 });

  const { chargeId } = await req.json();

  // Verify this charge belongs to the requesting user
  const charge = await stripe.charges.retrieve(chargeId);
  if (charge.metadata.userId !== session.userId) {
    return new Response('Forbidden', { status: 403 });
  }

  const refund = await stripe.refunds.create({ charge: chargeId });
  return Response.json({ refund });
}

The pattern here

Every one of these vulnerabilities follows the same shape: trust flows in the wrong direction. Client data is trusted server-side. External requests are trusted without verification. Operations are trusted without ownership checks.

Stripe can't fix that for you — it doesn't know what your application logic is supposed to do. That's the part you own. And if you've built with an AI assistant, there's a decent chance at least one of these is sitting in your codebase right now, waiting.

ARGUS DEEP SCAN

We test your live site,
not your source code.

Results in under 24 hoursReviewed by a human