I built a Supabase RLS audit that's just SQL wrapped in a prompt
Every AI coding agent will happily "audit your RLS" if you ask it to. Mine did. It found three issues, declared the rest fine, and on the second run found two different issues and declared the rest fine. That's the problem. An agent asked to go look enumerates what it happens to notice, then phrases everything it never checked as an all-clear. The finding isn't deterministic, so the all-clear is worthless. So I inverted it: the prompt doesn't ask the model to investigate anything. It hands the model the exact SQL, tells it not to substitute its own, and leaves it doing the one part it's genuinely good at - explaining what a row in the result means. I put it up at defencecore.com/audit with no signup wall. Why it's safe to paste into a production database Every query reads a Postgres catalogue view - pg_tables , pg_policies , storage.buckets . Those describe the shape of your database: table names, policy definitions, bucket settings. There is no statement in the prompt that selects from an application table, and none that writes. That's a property of the SQL, not a promise you're asking a model to keep. You can read all of it before you paste it. The prompt You are auditing the Row Level Security configuration of my Supabase project. STEP 1 - Run this query exactly as written. Do not modify it, and do not substitute your own queries. It reads only Postgres catalogue views (pg_tables, pg_policies, storage.buckets): it cannot read a single row of my application data, and it changes nothing. with findings as ( select 1 as rank, 'CRITICAL' as severity, 'Table has no Row Level Security - readable by anyone with the anon key' as finding, tablename as object from pg_tables where schemaname = 'public' and not rowsecurity union all select 1, 'CRITICAL', 'Policy grants read access to everyone (USING true)', tablename || ' -> ' || policyname from pg_policies where schemaname = 'public' and qual = 'true' union all select 2, 'HIGH', 'Policy accepts any new row (WITH CHECK true) - records can be reassigned', tablename || ' -> ' || policyname from pg_policies where schemaname = 'public' and with_check = 'true' union all select 2, 'HIGH', 'Storage bucket is public - any object URL downloads without auth', name from storage.buckets where public union all select 3, 'REVIEW', 'RLS enabled but no policy - table returns nothing to my own app', t.tablename from pg_tables t left join pg_policies p on p.schemaname = t.schemaname and p.tablename = t.tablename where t.schemaname = 'public' and t.rowsecurity and p.policyname is null ) select severity, object, finding from findings order by rank, object; STEP 2 - Report the results as a table, most severe first, naming the exact table, policy or bucket. If the query returns no rows, say so plainly instead of looking for something else to report. STEP 3 - For each finding, state in one sentence what someone holding my public anon key could actually do with it. STEP 4 - Describe the fix for each finding in words. Do NOT run, apply, or offer to run any statement that modifies my database - no ALTER, no CREATE POLICY, no DROP, no migration. I will make the changes myself. STEP 5 - Finally, list which of these findings could silently come back after a future migration or a future prompt, and explain why a one-time audit cannot catch that. Paste it into Claude Code, Cursor, or anything else connected to your project. No agent handy? Copy the with findings as (โฆ) block straight into the Supabase SQL editor - the SQL is the audit, the prompt is only the part that reads it back to you. What the five checks actually catch Tables with no RLS. Readable by anyone holding your anon key, which ships in your frontend bundle by design. This is the one everybody knows about and still the one that shows up most. Policies where qual = 'true' . RLS is on, a policy is attached, and it returns every row to everybody. This passes any audit that only asks is RLS enabled - including most dashboard checkmarks and most agents. WITH CHECK (true) . A user can reassign a record they own to somebody else. Almost always added to silence an insert error, not to deliberately grant anything. Public storage buckets. Correct for avatars. Wrong for the invoices and scanned documents that end up in the same bucket because public buckets are where uploads work on the first try. RLS on with no policy. Not a breach - but it's why a feature quietly returns an empty list instead of an error, and it's the usual prelude to somebody disabling RLS to "fix" it. Step 5 is the point The interesting step isn't 1 through 4. It's 5: which of these can come back silently? All of them. A migration re-creates a table without its policy. A prompt adds USING (true) to unblock a broken query. Someone flips a bucket public to debug an upload at 1am. Your audit output looks identical the next time you run it, because you don't run it again. That gap is why I ended up building Defencecore in the first place. A catalogue snapshot tells you a table is open; it can't tell you who already read it. That's in your Supabase logs, and on the lower plans those expire in days - Defencecore reads them as they arrive and keeps the log lines plus the incident built from them, so the evidence outlives the retention window that would have deleted it. Read-only, so it can explain a problem and never cause one. Run the prompt first though. It's free, it takes thirty seconds, and roughly everyone I've handed it to has found at least one REVIEW row they didn't expect. Top comments (0)
Comments
No comments yet. Start the discussion.