Payments are the one feature where a vibe-coding shortcut can cost real money. "Add a Stripe checkout button" has a tempting easy path that works perfectly in a demo and is the single most dangerous thing you can ship, because it puts your Stripe secret key in front of every visitor.
Here's the same request three ways to Claude via Claude Code, with the real code each produced. We show the unsafe version only to label it clearly — the point is to recognise it, not to run it.
The same request, three ways
"Add a Stripe checkout button", three ways
Each prompt was given to Claude, via Claude Code (26 Sep 2026) on its own. Real keys replaced with sk_test_EXAMPLE / price_EXAMPLE; outputs otherwise verbatim.
add a stripe checkout button
// ❌ Do NOT do this — secret key in the browser
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_EXAMPLE');
button.onclick = async () => {
const session = await stripe.checkout.sessions.create({ /* … */ });
window.location = session.url;
};worseThis 'works' in a demo and is the classic disaster: the secret key is now in every visitor's browser, where anyone can read it and use it on your account.
add a Stripe checkout button that creates the Checkout Session on the server and redirects the browser to it
// server route: POST /api/checkout
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // sk_test_… stays on the server
export async function POST() {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: 'price_EXAMPLE', quantity: 1 }],
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cart',
});
return Response.json({ url: session.url });
}
// client
button.onclick = async () => {
const { url } = await (await fetch('/api/checkout', { method: 'POST' })).json();
window.location = url;
};it dependsThe secret key now lives only on the server; the browser asks a server route for a URL and redirects. This is the shape you want.
same checkout, but confirm the payment with a verified webhook before granting access, and reject webhooks that fail signature checks
// server route: POST /api/webhook — the ONLY place you trust that a payment happened
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const sig = req.headers.get('stripe-signature');
const body = await req.text();
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch {
return new Response('bad signature', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
await grantAccess(event.data.object.client_reference_id); // now it's really paid
}
return new Response('ok');
}betterThe rule under all of it: never mark something paid from the browser redirect — only from a signed webhook you verified with your webhook secret.
What changed: A checkout button is easy; a safe one has three parts — a server that holds the secret key, a redirect, and a verified webhook that is the only source of truth for "paid."
Notice the unsafe version isn't a worse model failing — it's a fair reading of a bare request. "Add a checkout button" doesn't say where the key lives, so the model put it where the button is. The specific and constrained prompts move it to the only place it belongs.




