RLS Write-Leak: The Silent Data Exposure That Lets Any User Modify Any Row
You enabled RLS. SELECT is locked down — users only see their own rows. You tested it, it works.
But can any authenticated user INSERT/UPDATE/DELETE any row?
Most vibe-coded apps miss this. The dashboard shows green shields. pg_policies shows policies. But the write path is wide open.
The Write-Leak Pattern
-- Looks correct at first glance
create policy "owner_read" on documents for select
using (auth.uid() = user_id);
-- Missing: INSERT, UPDATE, DELETE policies!
-- Result: ANY authenticated user can write to ANY row
Postgres RLS is per-command. A SELECT policy does nothing for INSERT/UPDATE/DELETE. If you don't explicitly policy-write operations, they fall through to grants — and GRANT INSERT ON documents TO authenticated is the default.
The 3 Silent Write-Leak Modes
| Leak Type | Policy Gap | Impact |
|---|---|---|
| Blind Insert | No INSERT policy + GRANT INSERT TO authenticated
|
Any user creates rows with any user_id — impersonation, data poisoning |
| Privilege Escalation |
UPDATE policy only checks USING, missing WITH CHECK
|
User updates their row → changes user_id to victim's → now owns victim's data |
| Cascade Delete | No DELETE policy |
Any user deletes any row — ransomware, data destruction |
Why This Survives Testing
// Your test (passes)
const { data } = await supabase.from('documents').select()
// Returns only your rows ✓
// Attacker's test (also passes - silently!)
const { error } = await supabase.from('documents')
.insert({ user_id: 'victim-id', content: 'pwned' })
// error = null, row created with victim's user_id
The insert succeeds silently. No error. The row now belongs to the victim. Your app shows it to the victim. The attacker just poisoned their data.
The Fix: Complete Command Coverage
Every table needs all four commands covered:
-- 1. Enable RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- 2. Owner: full access
CREATE POLICY "owner_all" ON documents
FOR ALL USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- 3. Org members: read only (multi-tenant)
CREATE POLICY "org_read" ON documents
FOR SELECT USING (org_id = current_org_id());
Critical: WITH CHECK on write policies. Without it, UPDATE can change the row to violate the USING condition post-write.
The 9-Catalog Audit (Run in SQL Editor)
-- 1. Tables missing RLS
select c.relname from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and c.relrowsecurity = false;
-- 2. Tables with SELECT policy but no INSERT policy
select distinct p.polrelid::regclass as table_name
from pg_policy p
where p.polcmd = 'r' -- SELECT only
and not exists (
select 1 from pg_policy p2
where p2.polrelid = p.polrelid and p2.polcmd in ('a','*') -- INSERT/ALL
);
-- 3. Write policies missing WITH CHECK
select p.polrelid::regclass, p.polname
from pg_policy p
where p.polcmd in ('a','*','w','u','d') -- write commands
and p.polwithcheck is null;
-- 4. Grants to authenticated without policies
-- (run per table)
Or use the free tool: github.com/cekuu35/supabase-rls-leak-demo — runs 47 checks including all write-leak vectors.
Real-World Impact
Case 1: SaaS with user-generated content
- Attacker inserts rows with
user_id = admin_id - Admin sees attacker's content in their dashboard
- XSS via stored content → full account takeover
Case 2: Multi-tenant B2B
- User in Org A updates
org_idto Org B's ID - Now sees/controls Org B's data
- No error, no audit log entry
Case 3: E-commerce orders
- Any user deletes any order
- Financial records destroyed
- No
DELETEpolicy = open season
The "Green Shield" Illusion
Supabase dashboard shows ✅ RLS Enabled + ✅ Policies Exist.
But it doesn't verify command coverage. A table with only SELECT policy gets the same green shield as a fully locked-down table.
Your 5-Minute Audit
# 1. Clone free tool
git clone https://github.com/cekuu35/supabase-rls-leak-demo
cd supabase-rls-leak-demo
# 2. Run against your DB (read-only, safe)
npm install
npm run audit
Output shows:
- ❌ Tables missing RLS
- ❌ Tables with SELECT-only policies
- ❌ Write policies missing WITH CHECK
- ❌ Grants without policies
- ❌ Service role key in client bundle
- ...and 40 more checks
CTA
Free: Run the audit → see your write-leaks
$29 Kit: 47-check checklist + remediation SQL for every finding
$50/mo Guard: CI gate that fails build on any RLS gap
Next in series: "4 Security Holes Your AI-Generated Supabase App Has" — the Lovable/Cursor/Bolt patterns that leak data by default.
Top comments (0)