Most indie developers ship their SaaS, celebrate, and move on. Security feels like something for "later" — for when you have real users, real money, real problems.
Here's the thing: the vulnerabilities that cost you money aren't the Hollywood ones. No masked hackers, no zero-days. They're embarrassingly simple, and they're sitting in codebases right now generating real financial losses for real founders.
Let me show you the ones I see most often.
1. The race condition that lets users pay once and use twice
Imagine your credit system works like this:
const user = await db.from('usage').select('credits').eq('user_id', id)
if (user.credits < cost) {
return res.status(402).json({ error: 'Insufficient credits' })
}
await db.from('usage').update({ credits: user.credits - cost })
Looks fine. It's not.
Fire two API requests simultaneously from the same account. Both read the same credit balance before either one writes. Both pass the check. You just served two requests for the price of one — and your database has no idea it happened.
This isn't theoretical. It takes about 10 lines of JavaScript to exploit:
// This costs you money. Every time.
await Promise.all([
fetch('/api/expensive-endpoint', { method: 'POST' }),
fetch('/api/expensive-endpoint', { method: 'POST' }),
fetch('/api/expensive-endpoint', { method: 'POST' }),
])
The fix: never do read-then-write for anything financial. Use a database-level atomic operation:
UPDATE usage
SET credits = credits - $1
WHERE user_id = $2
AND credits >= $1
RETURNING credits;
If that returns no rows, the user didn't have enough credits. One round trip. Atomically safe. No race condition possible.
2. Trusting the client for billing identity
This one is subtle and extremely common:
router.post('/checkout', async (req, res) => {
const { email, plan } = req.body // ← here's your problem
const session = await stripe.checkout.sessions.create({
customer_email: email,
// ...
})
})
You're letting the client tell you who they are. An authenticated user can simply change the email in the request body and create a checkout session for someone else's account.
In a naive implementation, this can mean:
- Creating billing entries for arbitrary email addresses
- Probing whether specific emails have accounts
- In worst cases, confusing your billing system about who owns what subscription
The fix: never trust identity from the request body. Verify the session token server-side and extract the email from there:
router.post('/checkout', async (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '')
const { data: { user } } = await supabase.auth.getUser(token)
if (!user) return res.status(401).json({ error: 'Unauthorized' })
// Now you know exactly who you're billing
const email = user.email // from the verified token, not the body
})
3. The annual plan billing gap
This one is pure logic, not a vulnerability — but it bleeds money just as surely.
Most subscription SaaS reset usage credits monthly by listening to Stripe's invoice.paid event. Makes sense: Stripe bills monthly, Stripe fires the event, you reset.
Except for annual plans.
When a customer pays annually, Stripe fires invoice.paid once. Then silence for 364 days. If your credit reset logic lives entirely in that webhook handler, your annual subscribers get their credits on day 1 and nothing for the remaining 11 months of their paid subscription.
You just sold someone 12 months of service and delivered 1.
The fix: decouple credit resets from Stripe events entirely. Run a daily cron job that checks which users have a reset_date in the past and resets them regardless of billing cycle:
// Runs daily at 3 AM UTC
const dueUsers = await db
.from('usage')
.select('user_id, users!inner(plan)')
.lte('reset_date', new Date().toISOString())
for (const user of dueUsers) {
await db.from('usage').update({
credits: PLAN_CREDITS[user.users.plan],
credits_used: 0,
reset_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
}).eq('user_id', user.user_id)
}
Stripe events become a nice-to-have, not a single point of failure.
4. Storing recovery tokens in localStorage
Password reset flows are tricky. When a user clicks a reset link, you get a token in the URL hash. The tempting implementation:
// Looks harmless. It isn't.
const token = new URLSearchParams(window.location.hash).get('access_token')
localStorage.setItem('recovery_token', token)
You just stored a credential that can reset someone's password in a storage system accessible to every script running on your page — including browser extensions, third-party analytics, and any future XSS vulnerability.
Recovery tokens are short-lived, so the real-world risk is limited. But the principle is wrong: tokens that grant account control shouldn't touch localStorage.
The fix: let your auth SDK handle it. If you're using Supabase, detectSessionInUrl: true processes the recovery token automatically without you ever seeing it:
const supabase = createClient(url, key, {
auth: { detectSessionInUrl: true }
})
// The SDK handled the token. Just call getSession().
const { data: { session } } = await supabase.auth.getSession()
The pattern behind all of these
None of these are sophisticated attacks. They don't require reverse engineering or insider knowledge. They require:
- Reading your API response in the browser dev tools
- Sending two requests at the same time
- Knowing that annual billing exists
- Opening localStorage in the browser console
The common thread: trusting the client. Trusting that requests arrive one at a time. Trusting that Stripe events cover every edge case. Trusting that tokens are safe in the browser.
They're not.
The good news: fixing all four of these takes less than a day. The bad news: most SaaS founders never audit for them until something goes wrong.
Audit yours before something does.