TL;DR
Stripe handles most payment security, but your integration must be secure too. Always verify webhooks using the signature (never skip this). Keep your secret key server-side only. Use Stripe Checkout or Elements to avoid handling card data directly (PCI compliance). Never trust client-side price data - always set prices server-side. Validate that paying users are authenticated and own the resources they're paying for.
Why Stripe Security Matters for Vibe Coding
Stripe makes payments easy, but payment integrations are high-value targets. When AI tools generate Stripe code, they often create working checkout flows but miss critical security patterns like webhook verification or server-side price validation. A compromised payment integration can lead to financial loss and legal liability.
API Key Security
Stripe uses two types of keys. Handle them 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 notify your app about payment events. Always verify they're 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
Never trust prices from the client. Always set them server-side:
// 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
Always verify the user before creating payments or accessing 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). Never collect card numbers directly in your own forms. Use Stripe's pre-built components.
What if my webhook endpoint is down?
Stripe retries webhook delivery with exponential backoff for up to 3 days. Your webhook handler should be idempotent (safe to process the same event twice). Always return 200 quickly and process asynchronously if needed.
Should I use test keys in development?
Always. Use pk_test_ and sk_test_ keys in development. Never use live keys locally. Set up separate webhook endpoints for test and live modes. 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.
Start Free Scan