indiebreachpentest-101

How BOLA killed my MVP (and what I wish I'd done before launch)

A founder's first-person account of shipping a project management SaaS, discovering a BOLA on day 3, notifying 40 users, and what the $29 fix would have looked like before launch.

Alejandro Rodríguez
8 min

This is a composite story based on real patterns from founders I've spoken with. Names and identifying details are changed. The vulnerabilities and timelines are real.

I launched SyncBoard on a Tuesday.

By Thursday, a user emailed me: "Hey — why can I see someone else's projects when I change the number in the URL?"

That's how I found out I had a BOLA.

I had been building SyncBoard — a project management tool for small freelance agencies — for about four months. The stack was Next.js 14 with Supabase, built almost entirely with Cursor. The code was clean. TypeScript strict mode. Proper Zod validation on inputs. I was proud of it. I shipped it to a small beta list, posted on Reddit, got 40 signups in two days.

The user who emailed me was being polite. She was a developer herself and knew what she'd found. She didn't exploit it — she just went to /projects/1, /projects/2, /projects/3, and noticed that not all of those were her projects.

She was right. None of them were her projects. She was user 41. The projects with IDs 1 through 40 belonged to other users. She could read everything.

What I had built (and the bug I didn't notice)

Here's the route that was broken:

// src/app/api/projects/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const session = await getServerSession(authOptions)
  if (!session) return new Response('Unauthorized', { status: 401 })

  const project = await supabase
    .from('projects')
    .select('*')
    .eq('id', params.id)
    .single()

  return Response.json(project.data)
}

I had the auth check. I was checking that the user was logged in. What I wasn't checking: whether the project with that ID belonged to the user who was asking.

It looks fine at a glance. I looked at it a dozen times. It's clean code. It's readable. It does exactly what it says it does. What it doesn't do is check ownership. And because I didn't write a test that said "can user A access user B's project?", the question never came up. I tested the happy path. The happy path worked.

The forty minutes after the email

I read the email at 9:12 PM. By 9:52 PM I had the fix deployed. The fix itself took eight minutes. The other 32 minutes were spent checking every other route in my API to see if I'd made the same mistake elsewhere.

I had made it in six places.

// The fix — four words added to the query
const project = await supabase
  .from('projects')
  .select('*')
  .eq('id', params.id)
  .eq('user_id', session.user.id)  // ← this line
  .single()

That's the entire patch. .eq('user_id', session.user.id). One method call. Four words. Four months of building and I missed four words in six routes.

I notified all 40 users that evening. I explained what happened, what data was potentially visible, and what I'd done to fix it. Three users deleted their accounts. The rest stayed. The one who found it emailed back: "Thanks for the quick fix and the honest notice. That's how you handle it."

She was still a paid customer three months later.

What I didn't know before that Tuesday

I didn't know what BOLA stood for. I'd heard of SQL injection and XSS — those were the things I thought of when I thought of security. I had no mental model for authorization flaws as a distinct vulnerability class.

BOLA is Broken Object Level Authorization. It means your app checked that the user is who they say they are (authentication) but didn't check whether that user has permission to access the specific thing they're asking for (authorization). The OWASP API Top 10 has it at #1. It's been at #1 since the list existed.

I looked it up that Thursday night. I found the OWASP description. I found blog posts and CTF walkthroughs. I found that this exact pattern — query by ID without ownership check — is in every "intro to API security" piece ever written. I just hadn't read any of them.

The conversation I had with my investor

Two weeks later I had a call with the angel investor who'd put $15K into SyncBoard at pre-seed. I told her what happened. She asked: "Did you run any security testing before launch?"

I told her I hadn't. I told her I didn't know where to start and that real security testing seemed like something for companies with actual security budgets.

She said: "What would it have cost to catch this before launch?"

I didn't have an answer then. I do now.

A Sable Pre-Launch Check is $29. Running npx @sable/agent scan against your URL is free, takes 10 minutes, and catches exactly this class of bug — by actually testing whether authenticated user A can access authenticated user B's resources, not just by checking your code statically.

I would have paid $29 to not send that notification email to 40 users. I would have paid $29 to not spend 40 minutes at 9 PM auditing every route in my app. I would have paid significantly more than $29 for the confidence of knowing I'd actually checked.

What I do now before every launch

I run Sable the weekend before launch. Not because I think I definitely have a bug — but because I know I'm not thinking about the right questions when I'm in shipping mode. The agent asks the questions I forget to ask.

It tests whether any API routes are accessible without authentication, whether resources are readable across tenant boundaries, whether CORS leaks credentials to untrusted origins, whether there are secrets in the JavaScript bundle, and whether admin routes are reachable to non-admin authenticated users.

It's not a substitute for a full security review. But it's the difference between shipping with known basics covered and shipping hoping nothing comes up.

How to check your own routes right now

Before you do anything else: open your codebase, go to src/app/api/, and grep for database queries that include a user-owned resource ID:

grep -r ".eq('id'" src/app/api/
# or for Prisma:
grep -r "findUnique\|findFirst" src/app/api/

For every one of those queries, ask: "Does this query also check that the resource belongs to the current user?" If the answer is "it checks that the user is logged in but not that this resource is theirs" — you have a BOLA.

The fix is always the same:

// Vulnerable
const item = await db.item.findUnique({ where: { id: params.id } })

// Fixed
const item = await db.item.findUnique({
  where: { id: params.id, userId: session.user.id }
})

Then run the automated test so you catch the ones you missed:

npx @sable/agent scan https://your-app.com

Free. No sales call. In your terminal. Before you launch. Made by a dev who got tired of seeing this same bug show up in post-launch incident reports from founders who had no idea this was something to check for.

Ship securely. Your users' data deserves the eight minutes of effort it takes to fix this.

If you found a BOLA in your own codebase after reading this: that's the point. That's a good outcome. Fix it, run the scan, and ship knowing you checked.