Three weeks ago I got an email that made my stomach drop: a user asking why they could see another customer's data in the admin panel of a side project I'd just launched.
Turned out the admin route was checking if you were logged in, not if you were actually an admin. Any authenticated user could hit the URL directly and see everything. Nobody exploited it maliciously, the user who found it was just confused and honest enough to email me instead of screenshotting it. I got lucky.
This was my fourth SaaS side project. Every single one had hit some version of the same wall: auth, billing, admin, teams, all rebuilt slightly differently each time, all slightly buggy in new and exciting ways because I was moving fast and reinventing basics I'd already gotten right (and wrong) three times before.
After that email, I stopped. Spent real time hardening the boring stuff properly instead of racing to the feature that actually made this product different: role-based access done right, webhook signature verification, rate limiting, the works. Then packaged it so I never have to rebuild that foundation from zero again — that became ShipKit (shipkit.full-optimization.com), a Next.js boilerplate with the plumbing already stress-tested.
Not going to pretend this is some huge launch, it's a small one-time-purchase thing, $99 to start. But I'm curious: for the other builders here who've shipped multiple products, was there a specific moment (a bug, an email, a 2am panic) that made you stop winging the infrastructure layer? Do you rebuild it fresh every time, or have you built your own reusable base?
Mine was an export endpoint - the page was auth-gated, the CSV URL wasn't. What fixed it for me was moving the ownership check down into the data layer, so every query is tenant-scoped by default; per-route checks are where the one you forgot hides. I still rebuild the base, but now I start from the model layer.
That's basically our admin panel story with a different URL. per-route checks scale badly exactly because you have to remember to add them everywhere, forever, on every new route. moving it into the data layer means the default is safe and you have to opt out instead of opt in. that's the fix we're planning too, this thread's basically talked me into doing it sooner rather than later.
That incident is a painful but useful forcing function. For a small SaaS, I’d make tenant ownership a default invariant rather than a checklist: every data-access path accepts tenant/user context, authorization tests cover cross-tenant reads and writes, and staging has two tenants so a missing filter fails loudly. A short pre-launch “is this user allowed to access this object?” pass across admin, exports, billing, and background jobs catches the scary edge cases better than only reviewing the UI. The one-time, opinionated scope also makes sense—rebuilding auth is expensive, but adopting a narrow, trusted foundation is cheaper than a breach.
The two-tenant staging idea is the part I'm stealing. testing with one tenant, you never actually exercise the isolation logic, everything just happens to work because there's nothing else in the database to leak. with two you'd catch a missing filter on day one instead of after a customer emails you about it.
My moment was less dramatic but just as scary. Before launching a multi-tenant app I ran a structured security review and it found two things I'd never have caught by clicking around:
The lesson I kept: put tenant isolation in the database (Postgres row-level security on every table) so a sloppy route can't leak another customer's rows, and don't forget background jobs, which quietly see nothing under RLS unless you set the context for them.
Appreciate the writeup, especially the allowlist point, that's a mass assignment bug and it's an easy one to miss since everything "looks" scoped correctly in the UI.
honest answer: we don't have RLS on the tables right now, isolation is enforced at the query layer (every read/write scoped by org membership in the app code). it works, but it's exactly the kind of thing where one sloppy route slips through and suddenly doesn't. moving critical tables to RLS is on my list now, less because of a specific incident and more because "the app layer always gets it right" is a bet I'd rather not keep making.
the background jobs point is new to me though, didn't know RLS just goes silent under a different execution context instead of erroring. that seems like the kind of failure you don't notice until someone asks why a report is empty.
That's the honest answer most people don't give, and query-layer scoping is genuinely fine until it isn't. The failure mode is never the route you're looking at - it's the one someone adds in a hurry eight months from now, at 11pm, copying a pattern from a file that happened to be open.
If you ever do put RLS into ShipKit, the thing I'd build in from day one isn't the policies, it's the default-deny property. A helper that reads a per-connection setting (current_setting('app.current_org_id', true)), policies keyed on it, and no fallback - so an unset context yields zero rows rather than everything. That turns "someone forgot to scope it" from a data leak into an obviously empty page. Writing the policies is the easy part; getting the app to set the context on the same connection as the query is the fiddly part.
Three traps if you go there. RLS is not enforced against a superuser or the table owner, so you have to verify as your actual runtime role - connect, leave the context unset, select, assert zero rows. A policy with only USING still lets a user write rows into another tenant, so every write policy needs WITH CHECK too. And background jobs have no session, so no context, so they silently see nothing and report success.
I wrote the whole thing up this week, including the bug that got past all of it anyway (the auth library's user-update endpoint let a user rewrite their own orgId and role, which RLS then faithfully enforced): https://www.indiehackers.com/post/postgres-row-level-security-for-a-multi-tenant-saas-what-i-set-up-and-the-bug-that-slipped-past-it-anyway-be5ce43b78
the default-deny framing is the piece I was missing. I was thinking about RLS as "add policies," not as "make unscoped equal to nothing," and those lead to pretty different implementations. the current_setting-with-no-fallback approach makes total sense once you say it out loud.
the superuser trap is a good one to flag too, I would've tested it as the app's db user assumed that proved something and been wrong if the connection pool was set up with elevated permissions by default.
and the punchline in your writeup, RLS faithfully enforcing a role/orgId the user was able to rewrite themselves, that's almost funny in a bad way. it's a good reminder that RLS protects against sloppy queries, not against bad data getting written in the first place, the mass assignment bug upstream would've made the isolation technically correct and still wrong. going to go read the whole thing properly, though the link 404s for me right now, is it still live on your end?
The security incident makes the pain unusually concrete. Have other repeat SaaS builders actually chosen ShipKit instead of reusing their own boilerplate, and what made the $99 purchase worth switching?
honest answer, not yet — I only launched this week so there's no track record of people switching over from an already-established base. the pitch is really aimed at someone closer to where I was a few months back: two or three products in, still rebuilding auth/billing/webhooks slightly differently each time, hasn't gotten around to packaging their own base into something reusable.
if you already have a battle-tested base of your own that you're happy with, honestly the $99 probably isn't worth it for you, you're past the problem this solves. it's more for the version of me before I stopped and did this properly.
That’s a clear ICP boundary, especially the point where rebuilding stops being worth it. If you’re open to it, what’s the best email to reach you on?
thanks for asking — support@full-optimization.com works great, I actually read and reply to that one myself. looking forward to hearing what's on your mind
Thanks! I’ve just sent it over.
Looking forward to hearing your thoughts whenever you have a chance.
got it, thanks — will take a proper look and get back to you soon.