# Audit Row-Level Security in a Supabase Project

Yes. An agent reads every public Supabase table, its policies and its grants, and writes where row-level security is missing or wide open.

[Run this on Rush](https://cloud.getrush.ai/run/row-level-security-audit/supabase/).

## What the agent does

This run does not clone a repo and it does not open a pull request. Supabase is the connection: POST /me/connections with type mcp, name supabase, url https://mcp.supabase.com/mcp and auth mode oauth. You sign in in the browser authorize_url opens, and you pick the organization that holds the project. No personal access token is required. After status is connected, the session names the connection. A key run receives only the connections it names. A project-scoped server URL (the project_ref query on mcp.supabase.com) hides account tools such as list_projects and is the tighter choice when the audit is one project.

The agent calls list_tables for the public schema, get_advisors for the security advisors, and execute_sql for the catalog rows those two do not spell out. The SQL is read-only. It reads which tables have row-level security enabled, the policies on each (name, command, roles, and the USING and WITH CHECK expressions), and grants to the anon and authenticated roles. A policy whose expression is the literal true is listed even when row-level security is on, because enabled-with-true is how a table looks locked and is not. get_advisors is the lint list; execute_sql is the confirmation. A finding goes in the report only when both agree, or when the catalog shows a hole the advisor did not mention.

rls-audit.md has four lists. Tables in public with row-level security off. Policies whose condition is always true, with the policy name and command. Tables the anon role can select, insert, update or delete, from the grants. For each finding, a suggested policy: the role, the command, and an expression sketched from the table's columns (a user_id column suggests auth.uid() = user_id), marked as a suggestion. apply_migration is a tool on the same server and it can change the database. The prompt forbids it, and it forbids any DDL. execute_sql confirmations in the Supabase client are for destructive SQL and are not a substitute for that instruction.

Storage buckets are a separate tool group, disabled by default on the server, and this job does not enable them. Edge functions are out of scope too. The audit is tables, policies and grants in public. If list_tables returns more than one schema, the report still limits itself to public unless the prompt names another schema. Service-role grants are noted and not treated as an anon hole, because the service role is meant to bypass row-level security.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Use the Supabase connection. Call list_tables, get_advisors for security, and read-only execute_sql. Read every table in the public schema with its row-level security status, policies (including expressions) and grants. Write rls-audit.md listing tables without row-level security, policies whose condition is always true, tables the anon role can read or write, and a suggested policy for each finding. Do not call apply_migration. Read only; run no DDL."
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

rls-audit.md comes back as a session artifact with a name, a mime type and a url on api.prix.dev. The link downloads that file with no key until url_expires_at, about an hour, and the next session read issues a new one. GET /o/{org}/sessions/{id}/artifacts/rls-audit.md with the key returns it as well. No migration file is produced, and the database is unchanged.

The report is the four lists, each finding with the table name and, for a policy, the policy name and the expression that was true. The suggestion sits under the finding and is labeled as SQL you would review, not SQL that ran. Where get_advisors and the catalog disagree, both are quoted so you can see which one was stale. An empty list is written as empty, not omitted.

Before anyone runs a suggestion, they read it against the table. A suggested auth.uid() policy is wrong on a table that has no user column, and the report should have said the column was missing rather than inventing one. The transcript shows the execute_sql text, so a reviewer can re-run the same select in the Supabase SQL editor. Nothing was applied.

## Run it on every schema change

When a migration merges, your CI job POSTs this body to https://api.prix.dev/o/{org}/sessions with the git sha as the Idempotency-Key. A retry of the same sha returns the same run. The next merge uses a new key, because a completed run keeps its key. There is still no resources URL: the schema is read through Supabase, not through a clone. If you also want the migration diff from GitHub, that is a different run, one repository per session.

The key's creator, with an admin session, sets webhook_url to a public https endpoint. Rush POSTs run.completed within about 30 seconds of the turn ending, signed per Standard Webhooks. Pull rls-audit.md from the session artifacts. result in the webhook stops at 16 KiB. A non-2xx answer is retried three times, 2, 4 and 6 minutes apart, with the same webhook-id, then dropped.

## Why run it on Rush?

Row-level security fails open in the ways this report lists: the flag off, a policy of true, or a grant to anon that nobody noticed after a migration. Checking that in the Table Editor is one table at a time. The agent runs list_tables, get_advisors and read-only SQL in a sandbox and writes every public table into one file, with the queries kept on the session so you can see the expression that was judged always true.

A project with several schemas, or several projects in one organization, is several sessions, not a longer laptop script. Each session has its own sandbox. The key's max_concurrent is 4 unless you set it otherwise, and the next start is 429. The sandbox parks when the turn ends, costs nothing while parked, and is kept for 7 idle days, so a follow-up can ask for the authenticated grants on one table without scanning public again.

## Frequently asked questions

### Will it enable row-level security or apply a policy?

No. The prompt forbids apply_migration and any DDL. execute_sql is for reads of the catalog. Suggestions are text in rls-audit.md. The Supabase project is the same after the run as before it.

### Does a policy of true count as protected?

No. The report lists it on its own, separate from tables that have row-level security off. Enabled with USING (true) for the command you care about still lets that role through. The expression is quoted from the catalog so you can see it.

### What about the service role?

The service role bypasses row-level security by design. The report can note those grants and does not file them as an anon exposure. The anon role, and a policy that applies to public, are the findings. authenticated is included when that role has a grant the advisors flagged.

### Which project is audited if I have several?

The one you granted at sign-in. Prefer the project-scoped MCP URL so list_projects is hidden and the tools default to that project. An unscoped connection can see every project in the organization you picked. The report should name the project ref it read.
