# I built a Supabase RLS audit that's just SQL wrapped in a prompt

> Source: <https://dev.to/muhammed_semri_d5a477c02b/i-built-a-supabase-rls-audit-thats-just-sql-wrapped-in-a-prompt-1pd5>
> Published: 2026-08-13 16:15:24+00:00

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](https://defencecore.com/audit) with

no signup wall.

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.

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.

**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

** WITH CHECK (true).** A user can reassign a record they own to somebody else.

**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.

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](https://defencecore.com) 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](https://defencecore.com) 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.
