A Lovable client portal where every tenant could read every other tenant
A document portal for accounting practices launched with generated Supabase policies that matched any authenticated user.Composite engagement. Details anonymised; figures are representative.
11 → 0
tables readable across tenants
- Platform
- Lovable
- Industry
- B2B SaaS · Professional services
- Duration
- 3 weeks
- Findings
- 2 critical, 2 high, 2 medium
What we walked into
A solo founder built the portal in Lovable over about six weeks and launched it to sixty accounting practices.
The engagement started because of a security questionnaire. A larger practice wanted to move its whole client base onto the portal and asked, in one line, how tenant isolation was enforced.
Every table in the project had row-level security enabled, and the Supabase dashboard showed all of them green.
Every engagement opens with a free consultation and a scoped audit. This one drew on the following:
Composite engagement. Details anonymised; figures are representative.
What we found
Severity is about consequence, not effort. A critical finding is one where a stranger can read data, move money, or destroy state.
- Critical
Policies that matched every authenticated user
All fourteen tables had row-level security enabled with a single policy of the shape USING (auth.role() = 'authenticated').
- Critical
Service-role key shipped in the browser bundle
A settings screen called an admin function directly from the client, and the service-role key was inlined into the built JavaScript through a VITE_ prefixed variable.
- High
Tax documents in a public storage bucket
Uploads went to a public bucket under paths of the form uploads/<client-id>/<original-filename>.
- High
One Supabase project for development and production
The local build, the preview builds, and the live app all pointed at the same project.
- Medium
No index on the tenant column
The documents and activity_log tables were scanned in full on every dashboard load.
- Medium
The client list fetched the whole table
The list view selected every column of every row and filtered in JavaScript, shipping 6.8 MB of JSON to render twenty-five visible rows.
The work we did
Delivered over 3 weeks, on branches, alongside a founder who kept shipping product the whole time.
- 01
Rewrote all fourteen row-level security policies around a workspace claim resolved from a membership table, with separate policies per operation instead of one permissive rule covering everything.
- 02
Built a policy test suite that runs each table as three synthetic roles - workspace owner, member of a different workspace, and anonymous - and fails the build if any of them reads a row it should not.
- 03
Rotated the service-role key, moved the four privileged calls into edge functions with their own authorization checks, and purged the key from the repository history.
- 04
Moved documents into a private bucket behind short-lived signed URLs, with the storage policy keyed to workspace membership rather than to the path.
- 05
Split the project into development, staging, and production, seeded staging with anonymised data, and enabled point-in-time recovery on production.
- 06
Ran a full restore drill end to end and wrote it up as a runbook: fourteen minutes from decision to a verified copy of the database.
- 07
Added composite indexes on the two hot tables and moved the client list to server-side pagination with explicit column selection.
- 08
Stood up CI with per-branch preview deploys, the policy tests as a required check, error tracking, and a one-command rollback.
Before and after
The same metrics, measured the same way on both sides of the work. Where a number got worse, it is in the table.
Tables readable across tenants
11 of 140 of 14Verified by an authorization test suite that runs on every pull request.
Dashboard p95 load
4.2 s780 msSame data volume. Indexes and pagination account for most of the change.
Client list payload
6.8 MB94 KBServer-side pagination plus selecting the eight columns the view renders.
Secrets in the client bundle
20Service-role key and a third-party API key, both rotated and scoped per environment.
Verified database restore
Never attempted14 minRehearsed end to end, then written up so the founder can run it alone.
Monthly infrastructure cost
$210/mo$240/moThree separate projects and point-in-time recovery cost more than one project with neither.
Composite engagement. Details anonymised; figures are representative. Figures are rounded from what we measured across the engagements this study is drawn from.
What it runs on now
The builder stayed in the picture where it earned its place. Nothing here was rewritten for the sake of rewriting it.
- Lovable
- React + Vite
- Supabase Postgres
- Supabase Auth
- Supabase Storage
- Edge Functions
- GitHub Actions
- Sentry
- Vercel
The part that stayed with me is that the dashboard said row-level security was on. It was on. It just did not mean what I assumed it meant.
Other write-ups
Different builder, same shape of problem.
- Replit
An internal ops tool that lost a week of data every time it redeployed
96.8% → 99.95% - measured uptime after the move
Read the write-up - Base44
Record-level authorization for a Base44 equipment-rental marketplace
6 → 0 - endpoints returning other users' records
Read the write-up - Claude Code
A Claude Code B2B app with no way to deploy it twice the same way
40 min → 6 min - commit to production, unattended
Read the write-up
Same builder, same symptoms?
Send us a repo link and a paragraph about what it does. The audit is free and the report is yours either way.