TL;DR
Stripe handles most of the payment security. Your integration is still yours to get right. Verify every webhook's signature, no exceptions. Keep the secret key server-side. Use Stripe Checkout or Elements so card data never touches your servers (that's what keeps PCI simple). Set prices on the server, because a price sent from the browser is whatever the attacker wants it to be. And check that the person paying is signed in and owns what they're paying for.
Why Stripe Security Matters for Vibe Coding
Stripe makes payments easy. That's also why payment code is such a good target. AI tools will happily generate a checkout flow that works on the first try and still skips webhook verification or trusts a price from the client. Either one costs you real money.
API Key Security
Stripe gives you two kinds of key, and they're handled very differently:
# .env.local (never commit)
# Publishable key - safe for client-side
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
# Secret key - server-side ONLY
STRIPE_SECRET_KEY=sk_live_...
# Webhook signing secret - server-side ONLY
STRIPE_WEBHOOK_SECRET=whsec_...
Never Expose Your Secret Key: The sk_live_ or sk_test_ secret key must never appear in client-side code, version control, or logs. If exposed, rotate it immediately in the Stripe Dashboard. Someone with your secret key can create charges, issue refunds, and access customer data.
Webhook Verification
Webhooks tell your app a payment happened. Before you believe one, check it actually came from Stripe:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get('stripe-signature');
if (!signature) {
return new Response('Missing signature', { status: 400 });
}
let event: Stripe.Event;
try {
// CRITICAL: Verify the webhook signature
event = stripe.webhooks.constructEvent(
body,
signature,
webhookSecret
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
// Now safe to process the event
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutComplete(session);
break;
case 'customer.subscription.deleted':
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCanceled(subscription);
break;
}
return new Response('OK', { status: 200 });
}
Common AI-Generated Mistake: AI tools sometimes skip webhook verification or use JSON.parse(body) directly. This allows attackers to send fake webhook events. Always use stripe.webhooks.constructEvent() with the signature.
Server-Side Price Validation
Don't trust a price from the client. Ever. Look it up on the server:
// DANGEROUS: Using client-provided price
app.post('/create-checkout', async (req, res) => {
const { price } = req.body; // Attacker can send any price!
const session = await stripe.checkout.sessions.create({
line_items: [{
price_data: {
currency: 'usd',
unit_amount: price, // VULNERABLE!
product_data: { name: 'Product' },
},
quantity: 1,
}],
mode: 'payment',
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cancel',
});
});
// SAFE: Use server-side pricing
const PRODUCTS = {
'pro-monthly': { priceId: 'price_xxx', name: 'Pro Monthly' },
'pro-yearly': { priceId: 'price_yyy', name: 'Pro Yearly' },
};
app.post('/create-checkout', async (req, res) => {
const { productId } = req.body;
// Validate product exists
const product = PRODUCTS[productId];
if (!product) {
return res.status(400).json({ error: 'Invalid product' });
}
// Use predefined price ID
const session = await stripe.checkout.sessions.create({
line_items: [{ price: product.priceId, quantity: 1 }],
mode: 'subscription',
success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://example.com/cancel',
});
res.json({ url: session.url });
});
User Authentication in Payments
Check who the user is before you create a payment or open their billing:
// Associate payments with authenticated users
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return new Response('Unauthorized', { status: 401 });
}
// Get or create Stripe customer for this user
let customerId = await getStripeCustomerId(session.user.id);
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email,
metadata: { userId: session.user.id },
});
customerId = customer.id;
await saveStripeCustomerId(session.user.id, customerId);
}
// Create checkout with customer attached
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: 'price_xxx', quantity: 1 }],
mode: 'subscription',
success_url: '...',
cancel_url: '...',
client_reference_id: session.user.id,
});
return Response.json({ url: checkoutSession.url });
}
Protecting Customer Portal
// Customer can only access their own billing
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return new Response('Unauthorized', { status: 401 });
}
const customerId = await getStripeCustomerId(session.user.id);
if (!customerId) {
return new Response('No billing account', { status: 404 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: 'https://example.com/account',
});
return Response.json({ url: portalSession.url });
}
Handling Webhook Events Safely
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.client_reference_id;
if (!userId) {
console.error('Checkout without user ID:', session.id);
return;
}
if (session.payment_status !== 'paid') {
return;
}
await prisma.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
subscriptionId: session.subscription as string,
subscriptionStatus: 'active',
},
});
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
if (invoice.status !== 'paid') {
return;
}
const customerId = invoice.customer as string;
const user = await prisma.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (!user) {
console.error('Invoice for unknown customer:', customerId);
return;
}
await prisma.user.update({
where: { id: user.id },
data: {
subscriptionStatus: 'active',
currentPeriodEnd: new Date(invoice.lines.data[0].period.end * 1000),
},
});
}
Stripe Security Checklist
- Secret key stored in environment variable, never in code
- Publishable key used only for Stripe.js/Elements
- All webhooks verify signature with constructEvent()
- Prices set server-side using Price IDs, not client values
- User authenticated before creating checkout sessions
- Checkout sessions include client_reference_id for user tracking
- Customer portal only accessible to owning user
- Webhook endpoint uses HTTPS
- Payment status verified before granting access
- Idempotency keys used for critical operations
- Test mode keys used only in development
- Restricted API keys used where possible
Idempotency for Safe Retries
import { randomUUID } from 'crypto';
async function createPaymentIntent(userId: string, amount: number) {
const idempotencyKey = `${userId}-${amount}-${Date.now()}`;
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency: 'usd',
customer: await getStripeCustomerId(userId),
},
{
idempotencyKey,
}
);
return paymentIntent;
}
Do I need to be PCI compliant?
If you use Stripe Checkout or Stripe Elements, card data never touches your servers and you qualify for the simplest PCI compliance (SAQ A). Don't collect card numbers in your own forms. Use Stripe's pre-built components.
What if my webhook endpoint is down?
Stripe retries delivery with exponential backoff for up to 3 days. So your handler has to be idempotent: processing the same event twice can't double anything. Return 200 quickly and do slow work asynchronously.
Should I use test keys in development?
Yes. Use pk_test_ and sk_test_ keys in development and keep live keys off your laptop. Give test and live mode separate webhook endpoints. Test mode transactions don't charge real money.
How do I handle subscription upgrades/downgrades?
Use the Stripe Customer Portal for most billing changes, or modify subscriptions server-side. Always verify the user owns the subscription before making changes. Handle proration according to your business rules.
Scan Your Stripe Integration
Find webhook vulnerabilities, exposed keys, and pricing issues before they cost you.