I Ran My SaaS Through a Security Audit and I Was Cooked

I've been building AppTrack for a few months now. It's a job tracking app with AI features like cover letter generation, interview prep, and resume analysis. People are paying for it. It works.
But a couple weeks ago I decided to actually look at my API routes with security in mind. Not because something happened. Just because I had that nagging feeling you get when you've been shipping fast and never really stopped to check.
How This Happens
AppTrack is a Next.js app with Supabase on the backend. Each API route is its own file under app/api/. There's no global auth middleware that covers everything. Every route is responsible for checking its own auth.
When you're heads down building features, it's easy to forget that. You write a route, it works, you move on to the next thing. Multiply that by 30+ endpoints over a few months and you've got gaps.
The Audit
I used Claude Code to help me systematically review each route and CodeRabbit to verify the fixes on PR. The process wasn't one clean pass. It was several rounds of flagging an issue, fixing it, getting it reviewed, and catching something else in the process. Claude would find a missing auth check, I'd fix it and open a PR, CodeRabbit would flag something adjacent, and then I'd loop back through with Claude again.
If you want to do this yourself, you can start simple. Grep your route files for your auth function. Whatever isn't calling it is your starting point.
Finding 1: Endpoints With No Auth
The first thing I found was /api/auth/check-new-user. This route checks whether a user needs onboarding. The problem? It was accepting a userId from the request body. Anyone could POST any user ID and check their onboarding status.
Before:
After:
Simple fix. Get the user from the session instead of trusting the request body. But the fact that this was live for months is humbling.
Finding 2: Auth Without Authorization
I also found /api/debug/plans. This route lets you inspect subscription plans in the database. It checked that you were logged in, but it didn't check that you were an admin. Any authenticated user could hit it and see plan internals.
This is the classic authentication vs. authorization mix-up. The route knew who you were but didn't care what you were allowed to do.
For that one I wrote a requireAdmin() guard:
Nothing fancy. But now every admin route can just do:
Two lines. Should've had this from the start.
Finding 3: RLS Silently Swallowing Extension Requests
AppTrack has a browser extension that lets you save jobs directly from job boards. The extension authenticates with a Bearer token. It doesn't use Supabase session cookies like the main app does.
Supabase Row Level Security (RLS) policies use auth.uid() to scope queries to the current user. But auth.uid() comes from the session cookie. No cookie, no auth.uid(). And when auth.uid() is null, RLS doesn't throw an error. It just returns empty results.
So extension users were hitting the API, getting authenticated successfully, and then getting back... nothing. The queries were running fine. RLS was just silently filtering out every row.
Users thought the extension was broken. It wasn't. The auth was succeeding at the application layer but failing silently at the database layer.
The fix was to use Supabase's service role client for extension-authenticated requests. Since the user has already been verified through our own auth layer, we can safely bypass RLS:
If you have two auth paths hitting the same database, test both of them explicitly. RLS is great, but it's a safety net, not a substitute for understanding how your auth actually flows.
The Smaller Stuff
A couple other things I caught that are worth mentioning:
Auth cookies disappearing on redirect. After email confirmation, users get redirected to a welcome page. The auth callback route used createClient() which writes session cookies through Next.js cookies(). But NextResponse.redirect() doesn't carry those cookies along. The browser gets a bare 302 with no Set-Cookie headers. So users would confirm their email, get redirected, and not be logged in. I fixed it by creating a createCallbackClient() that collects cookie writes and applies them directly to the redirect response.
Unsanitized filenames on upload. Users upload resumes and I wasn't sanitizing filenames. Accented characters, Unicode, special characters. Any of those could break the upload or cause weird behavior downstream. Added a sanitizeFilename() utility with 30 tests covering the edge cases.
What I'd Do Differently
If I were starting over:
- Auth wrapper from day one. Something like
withAuth()orwithAdmin()that wraps route handlers. Don't rely on remembering to add auth checks to every new file. - Test both auth paths. If you have cookies and Bearer tokens, you effectively have two different auth systems. Treat them that way.
- Don't trust RLS as your only layer. It's a great safety net but it fails silently. Application-level auth should be the primary gate.
- Audit early. This took an afternoon, not a sprint. I should've done it a month ago.
Do This
If you're building a SaaS solo, especially with Next.js API routes, block out a couple hours and grep your routes. Look for anything that doesn't verify the session. Look for debug or admin routes that only check for authentication but not authorization. Look for places where you're trusting client-supplied IDs instead of pulling from the session.
You probably have gaps. I did.