security · · Streatix

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 build reference Lovable apps to study the patterns the tool ships by default. One of them, a 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.

This is the story.

The setup

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 standard user_id foreign keys you’d expect. Two test accounts, Alice and Bob, each with a handful of invoices in their own dashboard.

We built it the way a founder would: prompt Lovable with “build a multi-tenant invoicing tool with user accounts and an invoice list,” accept the output, deploy it. We didn’t review the schema. We didn’t touch the Supabase dashboard. The point was to see what the default produces.

Login worked. The dashboard rendered Alice’s invoices when Alice logged in, and Bob’s invoices when Bob logged in. Each user saw exactly their own data. The app 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. We assumed, like anyone reasonable would, that 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 would never let us do: calling the endpoint with the right session but a manually-edited query parameter that the frontend doesn’t expose. The route handler had an exploitable bit of code we’d spotted in passing:

// app/api/invoices/route.ts
export async function GET(request: Request) {
  const supabase = createServerClient()
  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 code, and unfortunately the kind of thing Lovable produces when you prompt it to “let admins view other users’ invoices” without specifying 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 absolutely catch in an audit. But it isn’t the bug this post is about. It was 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 said: “Wait. Even if the API is buggy, why is Supabase returning rows that don’t belong to Alice? Shouldn’t the database refuse?”

We checked the assumption. We logged into Postgres directly with Supabase’s anon key (the same key the API uses, with no special privileges) and ran:

SELECT * FROM invoices WHERE user_id = 'BOB_USER_ID';

It returned Bob’s rows. Cleanly. No authorization error. No row filtering. The database was happy to hand out any user’s data to anyone holding the anon key.

That’s when we knew the real problem wasn’t in the API layer.

The investigation

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 enable it, and the database becomes a public read endpoint for anyone who can produce a JWT, which in practice 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 itself enforces nothing. 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. When you create a table through the dashboard or via SQL, RLS is off. The Supabase docs are explicit about this; they tell you to enable it. The reason for the permissive default is practical: with RLS on and no policies, the table returns nothing to anyone, and a new user trying to bootstrap a project would be immediately confused.

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 the prompt-boundary problem strikes again: the AI generated exactly what was asked for and nothing else. The user gets a working dashboard with 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, the same direct database query that returned Bob’s rows now returns nothing when run with Alice’s JWT, and the API exploit above stops working, even though the buggy API code is still in place. The database itself 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', () => {
  it('user A cannot read user B\'s invoices via direct query', async () => {
    const aliceClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
      auth: { storage: aliceTokenStorage }
    })

    const { data } = await aliceClient
      .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 aliceClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
      auth: { storage: aliceTokenStorage }
    })

    const { error } = await aliceClient
      .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_role key (and Lovable apps do this more often than you’d hope), the policies above protect nothing; that key is privileged. Fixing RLS without rotating leaked service keys is a half-fix.
  • Cascade leaks via joins. If invoices has RLS but customers does 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 compares user_id to 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.

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.

What this post is about, said plainly

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.

If you built your SaaS on Supabase via Lovable, Bolt, or any AI tool, there’s a strong chance the RLS toggle on your tables is off. The free audit catches this in the first thirty seconds: we log in with one account and run a query against the database using another account’s ID. If we get data back, your RLS is off. If we don’t, we look at the next pattern.

Book a free audit →

If you want this for your own app

Thirty-minute audit, free. We curl the same endpoints and read the same files. List in your inbox within forty-eight hours.

Book a free audit