I do pre-launch payment reviews for small SaaS teams β the quick pass you want before
real cards start hitting your /checkout route. Different stacks, different teams, but
the same handful of bugs show up over and over. None of them are exotic. All of them are
the kind of thing that's fine in a demo and quietly expensive once you're live.
Here's the list I check first, with the bad version and the fix. If your checkout is
about to go live, read it with your own code open in the other window.
1. Trusting the amount the browser sends you
This is the big one, and it's everywhere.
// client
await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ priceId: 'pro', amount: 4900 }) // <-- attacker controls this
})
// server
const intent = await stripe.paymentIntents.create({
amount: req.body.amount, // straight from the request
currency: 'usd',
})
Anyone can open devtools and change amount to 1. The fix is boring and absolute:
the server decides the price. The client is allowed to say which product, never how
much.
const PRICES = { pro: 4900, team: 9900 } // cents, server-side source of truth
const amount = PRICES[req.body.plan]
if (!amount) return res.status(400).json({ error: 'unknown plan' })
const intent = await stripe.paymentIntents.create({ amount, currency: 'usd' })
If your prices live in a database or a config table, look them up by ID server-side.
Never let a number from the request reach paymentIntents.create.
2. No idempotency key, so a retry double-charges
Mobile users tap twice. Networks drop the response after the charge succeeds. Your own
frontend retries on timeout. Without an idempotency key, each of those creates a brand
new charge.
// one tap, one network hiccup, two charges
await stripe.paymentIntents.create({ amount, currency: 'usd' })
Stripe gives you the fix for free β pass a key tied to the thing being paid for, not
a random value:
await stripe.paymentIntents.create(
{ amount, currency: 'usd' },
{ idempotencyKey: `order_${orderId}` }
)
Same key, same logical operation, at most one charge. Use the order/cart ID, not
crypto.randomUUID() β a fresh UUID on every retry defeats the entire point.
3. Fulfilling the order before the payment actually settles
The classic race. You create the PaymentIntent, the client says "looks good," and you
immediately mark the order paid, send the welcome email, and provision the account β
while the charge is still processing and might fail.
const intent = await stripe.paymentIntents.create(...)
await db.orders.markPaid(orderId) // too early
await sendWelcomeEmail(user) // way too early
Payment confirmation is asynchronous. The only thing that should flip an order to "paid"
is the payment_intent.succeeded webhook:
// webhook handler
if (event.type === 'payment_intent.succeeded') {
const orderId = event.data.object.metadata.orderId
await db.orders.markPaid(orderId) // now it's real
await sendWelcomeEmail(...)
}
Put orderId in the PaymentIntent metadata when you create it so the webhook can find
its way back. If you're giving access before the webhook fires, you're giving away
product to failed payments.
4. The webhook endpoint that trusts anyone
A webhook that doesn't verify the signature is an open POST that flips orders to paid.
Anyone who finds the URL can fake a payment_intent.succeeded.
// no verification β anyone can forge this
app.post('/webhook', (req, res) => {
const event = req.body
if (event.type === 'payment_intent.succeeded') grantAccess(event)
})
Verify with the signing secret, and verify against the raw body (parsing to JSON
first breaks the signature):
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET
)
} catch {
return res.status(400).send('bad signature')
}
// event is now trustworthy
})
5. Live keys committed to the repo
I find sk_live_β¦ in a committed .env, in a config file, or in the git history more
often than you'd want to believe. Once it's in a commit, deleting the file doesn't help β
it's in the history forever, and anyone who clones the repo has your money keys.
Check before you ship:
git log -p | grep -E 'sk_live_|whsec_|sk_test_'
If anything turns up: rotate the key in the Stripe dashboard immediately (rotating beats
scrubbing β assume it's compromised), then purge it from history with git filter-repo.
Secrets belong in the environment, never in the tree.
6. Discounts and coupons computed on the client
Same disease as #1, second location. If the discount is applied in the browser, the
discount is whatever the user wants it to be.
// client
const final = price * (1 - couponPercent) // user sets couponPercent to 1.0
Validate the coupon server-side, against your own records, and compute the final amount
there:
const coupon = await db.coupons.findValid(code) // null if expired/fake/used
const amount = coupon ? base - coupon.discountCents : base
A free-money coupon bug is the kind of thing that doesn't show up until someone posts
the trick on a forum.
7. Payment-status and admin endpoints with no row-level authorization
The checkout itself is locked down, and then /api/orders/:id returns anyone's order if
you change the number in the URL. Receipts, emails, partial card details, addresses β
straight out the side door.
// returns ANY order, not just the caller's
app.get('/api/orders/:id', async (req, res) => {
res.json(await db.orders.findById(req.params.id))
})
Scope every read to the authenticated user:
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await db.orders.findOne({ id: req.params.id, userId: req.user.id })
if (!order) return res.status(404).end() // 404, don't confirm it exists
res.json(order)
})
The same hole shows up on admin/payment-status pages that check "is logged in" but not
"is allowed to see this record." Authentication is not authorization.
The 10-minute self-check before you launch
- Can I change the price in devtools and pay less? (#1, #6)
- Does a double-tap or a retry create two charges? (#2)
- Does anything get fulfilled before the webhook confirms payment? (#3)
- Does my webhook verify the Stripe signature on the raw body? (#4)
- Is there a live key anywhere in my git history? (#5)
- Can I read someone else's order by changing the ID in the URL? (#7)
If any answer makes you wince, fix that one first.
I run this as a fixed-scope pre-launch scan: I map your checkout flow, find the
launch-blockers, and send a prioritized fix list β no code changes, just findings you can
act on. The checklist and example code I work from is open here:
github.com/galakurpi/stripe-prelaunch-security-checklist
If you're about to go live and want a second set of eyes, I'll do a free 5-minute look at
your checkout first so you can see how I work before paying for anything. Happy to answer
Stripe questions in the comments too.












