{"slug": "i-built-a-supabase-rls-audit-that-s-just-sql-wrapped-in-a-prompt", "title": "I built a Supabase RLS audit that's just SQL wrapped in a prompt", "summary": "A developer built a deterministic SQL-based audit for Supabase Row Level Security, wrapped in a prompt that instructs the model to run exact queries against Postgres catalogue views rather than investigate on its own. The tool, available at defencecore.com/audit without a signup wall, reports findings such as tables without RLS and policies with 'USING true' that expose data to anyone with the anon key.", "body_md": "Every AI coding agent will happily \"audit your RLS\" if you ask it to. Mine did. It\n\nfound three issues, declared the rest fine, and on the second run found two\n\ndifferent issues and declared the rest fine.\n\nThat's the problem. An agent asked to go look enumerates what it happens to\n\nnotice, then phrases everything it never checked as an all-clear. The finding\n\nisn't deterministic, so the all-clear is worthless.\n\nSo I inverted it: the prompt doesn't ask the model to investigate anything. It\n\nhands the model the exact SQL, tells it not to substitute its own, and leaves it\n\ndoing the one part it's genuinely good at — explaining what a row in the result\n\nmeans. I put it up at [defencecore.com/audit](https://defencecore.com/audit) with\n\nno signup wall.\n\nEvery query reads a Postgres *catalogue* view — `pg_tables`\n\n, `pg_policies`\n\n,\n\n`storage.buckets`\n\n. Those describe the shape of your database: table names, policy\n\ndefinitions, bucket settings. There is no statement in the prompt that selects\n\nfrom an application table, and none that writes.\n\nThat's a property of the SQL, not a promise you're asking a model to keep. You\n\ncan read all of it before you paste it.\n\nYou are auditing the Row Level Security configuration of my Supabase project.\n\nSTEP 1 — Run this query exactly as written. Do not modify it, and do not\n\nsubstitute your own queries. It reads only Postgres catalogue views\n\n(pg_tables, pg_policies, storage.buckets): it cannot read a single row of my\n\napplication data, and it changes nothing.\n\n```\nwith findings as (\n  select 1 as rank, 'CRITICAL' as severity,\n         'Table has no Row Level Security - readable by anyone with the anon key' as finding,\n         tablename as object\n  from pg_tables\n  where schemaname = 'public' and not rowsecurity\n\n  union all\n  select 1, 'CRITICAL',\n         'Policy grants read access to everyone (USING true)',\n         tablename || ' -> ' || policyname\n  from pg_policies\n  where schemaname = 'public' and qual = 'true'\n\n  union all\n  select 2, 'HIGH',\n         'Policy accepts any new row (WITH CHECK true) - records can be reassigned',\n         tablename || ' -> ' || policyname\n  from pg_policies\n  where schemaname = 'public' and with_check = 'true'\n\n  union all\n  select 2, 'HIGH',\n         'Storage bucket is public - any object URL downloads without auth',\n         name\n  from storage.buckets\n  where public\n\n  union all\n  select 3, 'REVIEW',\n         'RLS enabled but no policy - table returns nothing to my own app',\n         t.tablename\n  from pg_tables t\n  left join pg_policies p\n    on p.schemaname = t.schemaname and p.tablename = t.tablename\n  where t.schemaname = 'public' and t.rowsecurity and p.policyname is null\n)\nselect severity, object, finding from findings order by rank, object;\n```\n\nSTEP 2 — Report the results as a table, most severe first, naming the exact\n\ntable, policy or bucket. If the query returns no rows, say so plainly instead\n\nof looking for something else to report.\n\nSTEP 3 — For each finding, state in one sentence what someone holding my\n\npublic anon key could actually do with it.\n\nSTEP 4 — Describe the fix for each finding in words. Do NOT run, apply, or\n\noffer to run any statement that modifies my database — no ALTER, no CREATE\n\nPOLICY, no DROP, no migration. I will make the changes myself.\n\nSTEP 5 — Finally, list which of these findings could silently come back after\n\na future migration or a future prompt, and explain why a one-time audit cannot\n\ncatch that.\n\nPaste it into Claude Code, Cursor, or anything else connected to your project. No\n\nagent handy? Copy the `with findings as (…)`\n\nblock straight into the Supabase SQL\n\neditor — the SQL *is* the audit, the prompt is only the part that reads it back\n\nto you.\n\n**Tables with no RLS.** Readable by anyone holding your anon key, which ships in\n\nyour frontend bundle by design. This is the one everybody knows about and still\n\nthe one that shows up most.\n\n**Policies where qual = 'true'.** RLS is on, a policy is attached, and it\n\n** WITH CHECK (true).** A user can reassign a record they own to somebody else.\n\n**Public storage buckets.** Correct for avatars. Wrong for the invoices and\n\nscanned documents that end up in the same bucket because public buckets are where\n\nuploads work on the first try.\n\n**RLS on with no policy.** Not a breach — but it's why a feature quietly returns\n\nan empty list instead of an error, and it's the usual prelude to somebody\n\ndisabling RLS to \"fix\" it.\n\nThe interesting step isn't 1 through 4. It's 5: *which of these can come back\nsilently?*\n\nAll of them. A migration re-creates a table without its policy. A prompt adds\n\n`USING (true)`\n\nto unblock a broken query. Someone flips a bucket public to debug\n\nan upload at 1am. Your audit output looks identical the next time you run it,\n\nbecause you don't run it again.\n\nThat gap is why I ended up building\n\n[Defencecore](https://defencecore.com) in the first place. A catalogue snapshot\n\ntells you a table is open; it can't tell you who already read it. That's in your\n\nSupabase logs, and on the lower plans those expire in days —\n\n[Defencecore](https://defencecore.com) reads them as they arrive and keeps the log\n\nlines plus the incident built from them, so the evidence outlives the retention\n\nwindow that would have deleted it. Read-only, so it can explain a problem and\n\nnever cause one.\n\nRun the prompt first though. It's free, it takes thirty seconds, and roughly\n\neveryone I've handed it to has found at least one `REVIEW`\n\nrow they didn't expect.", "url": "https://wpnews.pro/news/i-built-a-supabase-rls-audit-that-s-just-sql-wrapped-in-a-prompt", "canonical_source": "https://dev.to/muhammed_semri_d5a477c02b/i-built-a-supabase-rls-audit-thats-just-sql-wrapped-in-a-prompt-1pd5", "published_at": "2026-08-13 16:15:24+00:00", "updated_at": "2026-08-13 16:51:04.982281+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents"], "entities": ["Supabase", "Postgres", "defencecore.com", "Claude Code", "Cursor"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-supabase-rls-audit-that-s-just-sql-wrapped-in-a-prompt", "markdown": "https://wpnews.pro/news/i-built-a-supabase-rls-audit-that-s-just-sql-wrapped-in-a-prompt.md", "text": "https://wpnews.pro/news/i-built-a-supabase-rls-audit-that-s-just-sql-wrapped-in-a-prompt.txt", "jsonld": "https://wpnews.pro/news/i-built-a-supabase-rls-audit-that-s-just-sql-wrapped-in-a-prompt.jsonld"}}