Our Lovable app leaked every user's data on day one
How a Lovable-built SaaS shipped with Supabase RLS disabled, why we didn't see it immediately, and what the three-policy fix looks like.
We audit AI-generated codebases, and to learn what the tools ship by default we also build deliberately broken reference apps. One of them, a Lovable-built multi-tenant invoicing SaaS of the kind a thousand founders launch every week, leaked every user’s data to every other user on day one. The fix took three SQL policies. Finding the bug took most of an afternoon, mostly because we kept looking in the wrong place.
We built it the way a founder would
The reference app is small on purpose: Next.js App Router on the frontend, Supabase for auth and Postgres, deployed to Vercel. Three tables (users, invoices, customers) with the user_id foreign keys you’d expect. Two test accounts, Alice and Bob, each with a handful of invoices in their own dashboard.
We prompted Lovable with “build a multi-tenant invoicing tool with user accounts and an invoice list,” accepted the output, and deployed it. No schema review. We never opened the Supabase dashboard. The point was to see what the default produces, untouched.
Login worked. The dashboard rendered Alice’s invoices when Alice logged in, Bob’s when Bob logged in. Each user saw their own data and nothing else. It demoed cleanly.
The decision Lovable made for us
Lovable generated the schema with user_id columns on every table that needed tenancy: invoices.user_id, customers.user_id. The API routes filtered by the authenticated user. Every select had .eq('user_id', user.id) chained on.
That was reassuring. The data model knew about tenancy, the query layer used it, and we assumed the database itself was enforcing row ownership. Why else would the user_id column exist?
That assumption is the bug.
The symptom: one curl, every user’s invoices
We logged Alice into the app, copied her session cookie, and ran a manual test:
curl https://reference-app.test/api/invoices \
-H "Cookie: sb-access-token=ALICE_TOKEN"
Got Alice’s invoices back. Five rows. As expected.
Then we tried something the dashboard never would: the right session, plus a query parameter the frontend never sends. We’d spotted an exploitable line in the route handler in passing:
// app/api/invoices/route.ts
export async function GET(request: Request) {
const supabase = createServerClient()
const { data: { user } } = await supabase.auth.getUser()
const userId = new URL(request.url).searchParams.get('user_id') ?? user.id
const { data } = await supabase
.from('invoices')
.select('*')
.eq('user_id', userId)
return NextResponse.json({ invoices: data })
}
The searchParams.get('user_id') ?? user.id line lets the client override which user_id the query filters on. Sloppy, and exactly what Lovable produces when you prompt it to “let admins view other users’ invoices” without spelling out the authorization rules.
So we tried it:
curl "https://reference-app.test/api/invoices?user_id=BOB_USER_ID" \
-H "Cookie: sb-access-token=ALICE_TOKEN"
Bob’s invoices came back. Alice, logged in, reading Bob’s data.
That alone is a serious API-layer bug, a missing authorization check we’d catch in any audit. It isn’t the bug this post is about. It’s the bug that led us to the real one.
The wrong first theory
We assumed the bug was the API handler. Fix it: ignore the query parameter, always use the authenticated user’s ID, ship the patch. Standard auth-layer audit finding.
We were about to file it that way when one of us stopped: “Wait. Even if the API is buggy, why is Supabase returning rows that don’t belong to Alice? Shouldn’t the database refuse?”
So we checked. Instead of going through the app, we hit Supabase’s REST endpoint directly with the anon key, the same public key the browser ships with and nothing more:
curl "$SUPABASE_URL/rest/v1/invoices?user_id=eq.BOB_USER_ID" \
-H "apikey: $SUPABASE_ANON_KEY"
Bob’s rows came back. No authorization error, no filtering. That by itself is the tell: a table with RLS enabled but no matching policy returns nothing, default deny, so rows coming back to the anon role means RLS isn’t enforcing at all. A bare SELECT in the SQL editor would have hidden it, because the editor runs as a superuser role with BYPASSRLS and returns rows regardless; you’d have to SET ROLE anon first to see what a stranger sees. The anon REST path is the one a stranger can actually reach, and it was wide open.
That’s when we knew the real problem wasn’t in the API layer.
The database had no idea tenants existed
We opened the Supabase dashboard, navigated to the invoices table, and looked at the Authentication panel. There was a single toggle at the top:
Enable Row Level Security
RLS prevents anonymous access to rows by default.
The toggle was off.
We checked the other tables. customers: off. users: off. Every table Lovable created shipped with RLS disabled.
This is the failure mode we now call the open-database default. Supabase ships RLS off, Lovable doesn’t turn it on, and the database becomes a public read endpoint for anyone who can produce a JWT. In practice that means anyone who can sign up for the app.
The user_id columns Lovable so dutifully created are decorative. They exist for the API layer to filter on. Without RLS the database enforces nothing, and every reasonable assumption about tenancy in this app was wrong.
The root cause: two systems, each assuming the other handled it
Supabase’s defaults are deliberately permissive. Create a table with raw SQL, the way a generated migration does, and RLS is off. (The dashboard’s table editor now enables RLS by default; the SQL path Lovable’s migrations take does not.) The docs are explicit about this and tell you to enable it. The reason for the default is practical: with RLS on and no policies, the table returns nothing to anyone, and a new user trying to bootstrap a project hits a wall they don’t understand.
Lovable, generating code on top of Supabase, doesn’t enable RLS either. When it scaffolds a table, the migration looks like this:
CREATE TABLE invoices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES users(id),
amount integer NOT NULL,
customer_email text NOT NULL,
created_at timestamptz DEFAULT now()
);
No ENABLE ROW LEVEL SECURITY. No CREATE POLICY. The schema acknowledges tenancy through the user_id column, but enforces nothing.
Each side has a defensible reason. Supabase keeps the default permissive so new projects work. Lovable doesn’t enable RLS because nothing in the prompt asked for row-level security, and it generated exactly what was asked and nothing more. The user ends up with a working dashboard, multi-tenant-looking code, and a database that has no idea tenants exist.
The fix: three policies
Three SQL statements bring the database into agreement with the data model it pretends to have:
-- 1. Enable RLS on the table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- 2. Allow users to read only their own rows
CREATE POLICY "Users read own invoices"
ON invoices
FOR SELECT
USING (auth.uid() = user_id);
-- 3. Allow users to insert only rows owned by themselves
CREATE POLICY "Users insert own invoices"
ON invoices
FOR INSERT
WITH CHECK (auth.uid() = user_id);
The USING clause runs on SELECT (and on the read side of UPDATE / DELETE). It returns true only for rows where auth.uid() (the Supabase function that resolves to the JWT’s user ID) matches the user_id column. Any row that doesn’t match is silently filtered out.
The WITH CHECK clause runs on INSERT and UPDATE. It refuses to write a row where user_id doesn’t match the authenticated user. This blocks the other common bug: clients setting user_id to someone else on insert.
Repeat for customers and any other tenant-scoped table. You’ll also want UPDATE and DELETE policies for tables that need them, structured the same way.
After applying these, that same anon-key call comes back empty, and querying Bob’s rows with Alice’s JWT returns nothing. The API exploit above stops working with the buggy handler still in place. The database refuses.
A second fix: write the negative test
The negative test that would have caught this bug from the start:
// __tests__/rls.test.ts
import { describe, it, expect } from 'vitest'
import { createClient } from '@supabase/supabase-js'
describe('RLS enforcement on invoices', () => {
// A client authenticated as Alice: the public anon key plus Alice's access
// token. PostgREST runs the query as the `authenticated` role with
// auth.uid() resolving to Alice's id, exactly what a stranger's browser does.
const alice = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: `Bearer ${ALICE_ACCESS_TOKEN}` } },
})
it("user A cannot read user B's invoices via direct query", async () => {
const { data } = await alice
.from('invoices')
.select('*')
.eq('user_id', BOB_USER_ID)
expect(data).toEqual([])
})
it('user A cannot insert an invoice owned by user B', async () => {
const { error } = await alice
.from('invoices')
.insert({ user_id: BOB_USER_ID, amount: 100, customer_email: 'x@x.com' })
expect(error).toBeTruthy()
})
})
This test, run against the broken version of the app, would have failed on the first line. AI doesn’t write tests like this. We do.
An uneasy truce
RLS solves the “wrong user reads wrong row” pattern. It does not solve several adjacent ones, and a clean RLS policy is not the same thing as a hardened multi-tenant database. The things RLS doesn’t catch:
- Service-role keys bypass RLS entirely. If your client bundle leaks the
service_rolekey (and Lovable apps do this more often than you’d hope), the policies above protect nothing; that key is privileged. The same key handed to a route handler walks around these policies by design, which is its own finding. Fixing RLS without rotating leaked service keys is a half-fix. - Cascade leaks via joins. If
invoiceshas RLS butcustomersdoes not, and your API joins them, the customer rows come back unprotected. RLS is per-table; coverage needs to be complete. - Policies that look right but aren’t. A
USING (true)policy looks like a policy but lets everything through. A policy that comparesuser_idto a JWT claim that doesn’t exist silently allows nothing, which often gets “fixed” by making the policy more permissive, defeating the purpose. - Performance. RLS policies that aren’t indexed can turn cheap queries into table scans. We’ve seen RLS rollouts that fixed the security problem and broke the latency budget on the same afternoon.
- Storage is a separate boundary. RLS covers table rows, not stored files. A Supabase Storage bucket left public, or an S3 bucket the app opened, serves documents to anyone with the URL no matter how tight your row policies are. We cover that one in public S3 buckets.
The audit we’d ship for an app in this state would flag the API-layer bug, the missing RLS, the schema’s lack of policies, and at least three of the cascade and edge-case patterns above. The three-policy fix is the starting line, not the finish.
The columns say tenancy; the database doesn’t
A Lovable-built app with user_id columns looks like it has tenancy. The data model says it does. The query code says it does. The database, by default, does not.