When you prompt Claude Code, Cursor, or Lovable to build a SaaS dashboard, it writes clean React components that filter user data seamlessly:
// Typical AI-generated frontend query
const { data: myInvoices } = await supabase
.from('invoices')
.select('*')
.eq('tenant_id', currentTenant.id); // Looks safe in the UI
In your browser, everything works. User A only sees Organization A's invoices. User B only sees Organization B's invoices.
You think your app is secure.
In reality, your entire database might be completely public to any signed-in user.
The UI Illusion
Frontend filtering is cosmetic. True multi-tenant data isolation happens inside the PostgreSQL database engine via Row-Level Security (RLS).
- 01AI models prioritize making the UI look functional and often neglect backend database policies.
- 02Supabase exposes a direct REST API (PostgREST) to the browser; any user with DevTools can bypass your React filters.
- 03You can prove whether your app leaks data in 5 minutes by running a dual-session cross-tenant query test.
1. How the leak actually happens: The PostgREST Bypass
When an application connects to Supabase from the browser, it uses the public anon key and communicates directly with PostgreSQL over HTTP.
If an attacker logs in as User A (Tenant #101), opens Chrome DevTools, and removes the .eq('tenant_id', ...) filter, what happens?
// Attacker runs this in DevTools Console:
const { data, error } = await supabase
.from('invoices')
.select('*'); // Request ALL invoices across all tenants
If Row-Level Security is disabled or improperly configured, PostgreSQL will happily return every single invoice, customer email, and billing address in your database.
The React UI was filtering the data, but the database had no authorization rules.
2. The three common AI-generated RLS mistakes
When reviewing AI-generated Supabase projects, I find three recurring failure modes:
Mistake 1: Enabling RLS without adding policies
-- The AI enables RLS...
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- ...but forgets to add policies!
-- Result: All queries return empty arrays, breaking the app.
-- Then the founder or AI disables RLS to "make it work again."
Mistake 2: The USING (true) trap
To fix query errors, AI prompts often generate:
-- DANGEROUS: Grants full read access to anyone logged in
CREATE POLICY "Allow authenticated read" ON invoices
FOR SELECT TO authenticated
USING (true); -- User A can read User B's rows!
Mistake 3: Checking auth.uid() instead of tenant_id
In multi-tenant B2B SaaS apps, a user belongs to an organization. A policy that only checks auth.uid() = user_id prevents teammates from seeing each other's work, while failing to isolate tenant boundaries.
3. How to verify your app with a Dual-Session Test
Never assume your database is secure just because the dashboard looks right. Perform this simple Dual-Session Isolation Drill:
Action plan
5-Minute Cross-Tenant Verification Drill
- Step 1Create Two Test TenantsRegister User A under 'Acme Corp' and User B under 'Beta Industries' in your staging app.
- Step 2Obtain User A's JWT TokenLog in as User A, open DevTools Application Tab, and copy the Supabase auth access token.
- Step 3Execute Direct Cross-Tenant FetchUsing User A's session, issue a REST fetch targeting Tenant B's UUID. If the response returns rows, you have an active P0 data leak.
4. The Proper Fix: Enforcing Strict Tenant Isolation in RLS
To secure multi-tenant tables properly, enforce tenant membership directly in the PostgreSQL RLS policy using JWT app metadata or junction tables:
-- 1. Enable RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- 2. Create strict tenant-isolated SELECT policy
CREATE POLICY "Tenant isolation for invoices" ON invoices
FOR SELECT TO authenticated
USING (
tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid
OR
EXISTS (
SELECT 1 FROM tenant_members
WHERE tenant_members.tenant_id = invoices.tenant_id
AND tenant_members.user_id = auth.uid()
)
);
With this policy in place, even if an attacker queries supabase.from('invoices').select('*'), the database engine automatically discards all rows belonging to other tenants before returning the response.