Back to blog

The Five Vulnerabilities I Find in Every Indie SaaS

IDOR. Missing rate limits. Mass assignment. The same five vulnerabilities, over and over. None of them require a security team to fix. Most founders won't do it until after something breaks.

·
The Five Vulnerabilities I Find in Every Indie SaaS

The Five Vulnerabilities I Find in Every Indie SaaS

I've reviewed hundreds of indie SaaS apps at this point. From one-person side projects to teams that just raised their seed round. The tech stack changes—React, Vue, Rails, Django, whatever—but the security bugs? They're almost always the same.

I'm not talking about exotic zero-days or sophisticated attack chains. I'm talking about basic mistakes that show up in probably 80% of the apps I look at. The kind of stuff that gets you on Hacker News for the wrong reasons.

Here are the five I find everywhere. Fix these and you're already ahead of most of your competition.


1. IDOR: Insecure Direct Object Reference

This is the big one. The vulnerability that keeps me employed.

Here's how it works: your app has URLs like /api/invoices/12345 or /dashboard/projects/678. The number is an ID. The app looks it up and returns the data. Simple enough.

The problem? Most apps don't check if the user requesting /invoices/12345 actually owns invoice 12345. They just return it. So I change the number to 12346, 12347, and suddenly I'm looking at other people's invoices.

Real example I found last month:

app.get('/api/documents/:id', async (req, res) => {
  const doc = await Document.findById(req.params.id);
  res.json(doc);  // No user check. Just returns it.
});

The fix is simple but people skip it:

app.get('/api/documents/:id', async (req, res) => {
  const doc = await Document.findOne({
    _id: req.params.id,
    userId: req.user.id  // Make sure it belongs to them
  });
  if (!doc) return res.status(404).json({ error: 'Not found' });
  res.json(doc);
});

Every. Single. Endpoint. That returns a specific resource needs this check. I know it's tedious. Do it anyway.


2. Missing Rate Limiting

Your login endpoint. Your password reset. Your "contact sales" form. Your API that checks if a username is available. All of them are probably wide open to brute force.

I tested an app two weeks ago that let me try 10,000 password combinations against a single account in under five minutes. No CAPTCHA. No lockout. No rate limiting at all. I didn't even need to rotate IPs.

The founder's response: "We figured people would use strong passwords."

That's not how this works. Attackers have lists of millions of passwords from previous breaches. They'll try the most common ones against every account on your platform. If you don't slow them down, someone will get in.

You don't need anything fancy. Start with this:

const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 5,  // 5 attempts
  message: 'Too many attempts. Try again later.'
});

app.post('/login', loginLimiter, handleLogin);

Apply it to login, password reset, signup, anywhere that accepts user input that could be abused. Five attempts per 15 minutes won't annoy real users but it'll stop automated attacks cold.


3. Mass Assignment

This one usually shows up in Rails, Laravel, or any framework that makes it easy to update models directly from request parameters.

The pattern looks like this:

def update
  @user = User.find(params[:id])
  @user.update(user_params)  # Just updates whatever's in the params
  redirect_to @user
end

private

def user_params
  params.require(:user).permit(:name, :email, :bio)
end

Looks fine, right? Except what if I send a request with user[admin]=true in the body? If your user_params doesn't explicitly forbid it, and your database has an admin column, congratulations—I just made myself an admin.

I've seen this with role, plan, credits, verified, subscription_tier—any field that controls access or capabilities.

The fix is boring but necessary: whitelist exactly what users can update. Don't assume they'll only send the fields you showed them in the form.

def user_params
  params.require(:user).permit(:name, :email, :bio)  # That's it. Nothing else.
end

If you need to update sensitive fields, do it explicitly in a separate admin controller or service. Never trust user input to define what gets updated.


4. Information Leakage in Error Messages

Your error handling is probably telling attackers exactly how your system works.

I see this constantly:

{
  "error": "SQL syntax error near 'admin' at line 1. Check your query and try again."
}

Or:

{
  "error": "User with email attacker@example.com not found in database users_table"
}

Or my favorite:

{
  "error": "Invalid password for user john@company.com"
}

Each of these tells me something valuable. The first one confirms SQL injection is possible and shows me part of your query structure. The second confirms the email exists (user enumeration). The third also confirms the user exists and that I should keep guessing passwords.

Your error responses should be generic:

{
  "error": "Invalid credentials"
}

That's it. Same message whether the email doesn't exist, the password is wrong, or the account is locked. Don't help attackers narrow down their approach.

Log the detailed errors server-side for debugging. Never send them to the client.


5. Weak Session Management

Sessions that never expire. Tokens stored in localStorage. Password changes that don't invalidate existing sessions. Same session cookie working across different devices without any notification to the user.

Found one last month where the session token was just a base64-encoded user ID with a timestamp. I decoded it, modified the user ID to someone else's, re-encoded it, and I was logged in as them. No signature. No validation. Just trusting that users wouldn't figure out base64.

Here's the minimum you should be doing:

  • Use cryptographically random session tokens (not JWTs for web sessions—use server-side sessions)
  • Set expiration (30 days max for "remember me", shorter for sensitive actions)
  • Invalidate all sessions on password change
  • Store a session ID server-side so you can revoke specific sessions
  • Notify users when new devices/locations log in
// Instead of this:
const token = Buffer.from(`${userId}:${Date.now()}`).toString('base64');

// Do this:
const token = crypto.randomBytes(32).toString('hex');
// Store in database: { token, userId, createdAt, expiresAt }

Yes, it requires a database lookup on every request. That's the point. You need to be able to revoke sessions instantly when something goes wrong.


The Pattern Behind All of These

Look at these five vulnerabilities. What's the common thread?

They're all assumptions.

  • Assuming users won't guess other IDs
  • Assuming attackers won't automate requests
  • Assuming users will only send the fields you showed them
  • Assuming error messages don't matter
  • Assuming no one will look at how your tokens work

Security breaks when you assume the best case. Attackers assume the worst—and they're usually right.

The good news: none of these require a security team to fix. They're all straightforward code changes that a single developer can knock out in a day.

The bad news: most founders won't do it until after something goes wrong. They'll read this, think "yeah I should fix that," and then get distracted by the next feature request.

Don't be that founder. Pick one of these today. Fix it. Then do the next one. Your future self—and your users—will thank you.


If you want someone else to check your work, that's what we're building at patchli.st. But honestly? Start with this list first. Most breaches I see could've been prevented by getting these basics right.

Now stop reading and go audit your auth checks.