Your SaaS is probably leaking money right now — and you don't know it

javascript dev.to

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 })
Enter fullscreen mode Exit fullscreen mode

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' }),
])
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
    // ...
  })
})
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

The pattern behind all of these

None of these are sophisticated attacks. They don't require reverse engineering or insider knowledge. They require:

  1. Reading your API response in the browser dev tools
  2. Sending two requests at the same time
  3. Knowing that annual billing exists
  4. 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.

Source: dev.to

arrow_back Back to Tutorials