se-check
Audits any PostgreSQL database for Row Level Security and authorization issues, checking policies, grants, tenant-scoped tables, and function permissions to identify data leakage risks.
Audits Supabase databases for Row Level Security misconfigurations, including tables without RLS, permissive policies, user-metadata authorization, and unsafe SECURITY DEFINER functions, with static checks and a dynamic probe to find cross-tenant and cross-user data leaks.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@se-checkaudit the Supabase migrations I just wrote for RLS and tenant isolation holes"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
se-check
Find the holes in your Supabase Row Level Security before someone else does.
se-check audits a Supabase (or any Postgres) database for the authorization mistakes that leak
data between users and tenants: tables without RLS, policies that let every row through,
authorization based on metadata users can edit, SECURITY DEFINER functions anyone can call.
Static checks read your tables, grants, policies and functions, explain each hole in plain words and suggest a fix you can paste.
A dynamic probe (
--probe) tries the policies for real: it acts asanonand as users of two tenants on synthetic rows, inside a transaction it always rolls back.Where you work: a CLI for your terminal, a GitHub Action that annotates your pull requests, and an MCP server, so your AI coding agent can audit the migrations it just wrote before telling you it's done.
It reads metadata, never the contents of your tables.
Status: alpha (0.1.0). Not on PyPI yet: install it from source, as shown in Quick start.
Contents
Related MCP server: schema-guard-mcp
Why
On Supabase the anon key ships with your frontend, so RLS is the only thing standing between it and your data. The dangerous mistakes are the ones that look fine at a glance:
create policy "logged in users read documents" on documents
for select to authenticated using (true); -- every tenant reads every tenant
create policy "manage tasks" on tasks
for all to authenticated using (auth.uid() is not null); -- "logged in" is not authorization
create policy "my tenant" on profiles for select to authenticated
using (tenant_id = (auth.jwt() -> 'user_metadata' ->> 'tenant_id')::uuid);
-- users can edit user_metadata themselves: pick any tenant you likeSupabase's own advisors catch some problems, such as RLS disabled or a mutable search_path.
se-check focuses on multi-tenant isolation: it knows which tables are tenant-scoped, which
roles actually hold privileges, and how Postgres really evaluates a policy.
What it looks like
Against the deliberately vulnerable app in examples/vulnerable-app
(output trimmed):
$ se-check audit
se-check 0.1.0 · PostgreSQL 16.2 · schemas: public
CRITICAL public.invoices [rls-disabled]
Row Level Security is disabled on a table the API can reach
Without RLS these grants apply to every row of the table, whatever tenant or user it
belongs to (anon: SELECT, INSERT, UPDATE, DELETE; authenticated: SELECT, INSERT, UPDATE,
DELETE). Anyone holding a key for these roles can use them directly through the Data API.
fix: alter table public.invoices enable row level security;
-- then add policies for the access you actually want: with RLS on and no policies,
-- the API sees no rows.
HIGH public.delete_tenant(p_tenant uuid) [definer-no-caller-check]
SECURITY DEFINER function callable through the API without checking the caller
It runs with its owner's privileges, so RLS does not apply inside it, and anon,
authenticated can call it at /rest/v1/rpc/delete_tenant. Neither its body nor the
functions it calls look at who is calling (auth.uid(), auth.jwt(), ...).
fix: ...
revoke execute on function public.delete_tenant(p_tenant uuid) from public, anon, authenticated;
HIGH public.tasks [permissive-policy]
Policy "authenticated users manage tasks" does not restrict which rows can be read, inserted, updated or deleted
Its condition only checks that the user is logged in, so every account passes it. It
applies to: authenticated. The table has a `tenant_id` column, so users of one tenant
reach the rows of every other tenant.
...
14 findings: 1 critical, 7 high, 2 medium, 4 low
Fine on purpose? se-check accept <check> <object> --reason "why"With --probe it also catches what no definition shows. The UPDATE policy on profiles is
using (id = auth.uid()), which looks right, but nothing stops users from changing their own
role:
$ se-check audit --probe
se-check 0.1.0 · PostgreSQL 16.2 · schemas: public
Probe: reached all 12 tables it could try, with 168 steps, all rolled back. Details at the
end.
...
HIGH public.profiles [probe-self-escalation]
Users can set their own role
Logged in as a member of tenant A, inside a transaction it rolled back, se-check updated
her own row, setting role to 'admin'. UPDATE policies for authenticated: "users update
their profile".
fix: revoke update on public.profiles from authenticated;
grant update (<the columns users may change>) on public.profiles to authenticated;
-- then change role only server-side: the service role, an admin function, or a
-- trigger that rejects other changes
...
24 findings: 2 critical, 14 high, 3 medium, 5 lowQuick start
You need uv and Python 3.10 or later. Install se-check once:
uv tool install git+https://github.com/devhardesian/se-checkThen, from your Supabase project folder:
se-check audit # the local database, the one `supabase start` runs
se-check audit --linked # the hosted project you linked with `supabase link`
se-check audit --probe # the local database, trying the policies tooThat's it: se-check reads the local port from supabase/config.toml, and for --linked the
address supabase link saved, so it only asks for the database password (hidden as you type).
Not using the Supabase CLI? Run se-check audit anywhere and paste a connection string when it
asks (Dashboard → Connect → Session pooler); the password is asked separately. In scripts and
CI, pass the connection string with --dsn or $SE_CHECK_DSN, and the password for
--linked with $SUPABASE_DB_PASSWORD.
Static checks
Check | Finds | Severity |
| Tables in exposed schemas with RLS turned off | critical if an API role has grants on them, low otherwise |
| Permissive policies that are always true, or only check that the user is logged in ( | high on tenant-scoped tables and for UPDATE/DELETE; medium for open inserts, low for public reference data |
| Policies, and helper functions they call, that read | high |
| Auth hooks (e.g. | high |
| Triggers on | high |
|
| high |
|
| medium if callable via the API, low otherwise |
se-check checks prints the same list with a longer description of each check. How se-check
decides:
Privileges are real. A policy only counts if the role it targets actually holds the privilege on the table.
Tenant-scoped tables are the ones with a column like
tenant_id,org_id,organization_id,team_id,workspace_id,company_idoraccount_id. Add yours with--tenant-columnortenant_columnsinse-check.toml.Caller checks are followed. A function checks its caller when it reads
auth.uid()orauth.jwt(), or calls a helper that does, through any number of calls: yourhas_permission()counts by itself. Name helpers se-check cannot read (say, written in C) with--guard.Auth hooks are recognized by their grant to
supabase_auth_admin, or by[auth.hook.*]insupabase/config.toml.
Dynamic probe
The checks read definitions; se-check audit --probe also tries them. Inside one transaction,
which it always rolls back, it creates synthetic tenants A and B, users and rows in every
table, then acts as
anon: a request carrying only the anon key, which ships with your frontend;alice, a member of tenant A, andoutsider, a logged-in user of no tenant;mallory, a user who wrote tenant B into her ownuser_metadata, as anyone can withsupabase.auth.updateUser()(only when your policies read tenants from there),
and tries what an attacker would, filtering on the primary key as the Data API does. Whatever gets through is a finding:
Check | Finds | Severity |
| Rows the anon key reads or changes | critical on tables of tenants or users and for updates and deletes; medium for inserts, low for reads of public data |
| Rows of tenant B that the others read | high |
| Rows of tenant B they update or delete, rows they insert into B, rows of A they move into B | high |
| On tables whose rows belong to a user ( | high |
| Bob's rows she changes, or rows she inserts in his name | high |
| Alice setting her own | high |
When a static check flagged the same hole on the same table, the finding says it confirms it.
On the example app the probe reports ten findings: eight confirm a static one, and two no
static finding names: users promoting themselves on profiles, and mallory reading tenant B's
comments through a helper that trusts user_metadata. Every SAFE table stays quiet.
Personas that look like real users
If the personas didn't look like real users of your app, the policies would turn them all away and the probe would see nothing. So se-check works out from your schema what a real user has:
Tenancy from foreign keys. The tenant table is the one
tenant_idand friends reference, inpublicor in a private schema; a memberships table gets(alice, A)and(bob, B)like any other synthetic row; a profile that a signup trigger creates gets its tenant.JWT claims from your policies. If a policy or helper reads
auth.jwt() -> 'app_metadata' ->> 'tenant_id'(orraw_user_meta_datain auth.users), each persona carries their tenant there, and auth.users holds the same metadata.Your access token hook, from
[auth.hook.custom_access_token]or a function granted tosupabase_auth_admin, is called for each persona, as Supabase Auth does at sign-in.Reference data it can't make up. When a synthetic row can't be created (a CHECK rejects the made-up code of a plan, say), the rows that point at it don't give up: a nullable reference is left NULL, and a required reference to reference data (never to tenant or user rows) takes a row the table already holds. The database copies that row's key; se-check never sees it.
Coverage: what "no findings" means
A probe that reached few tables proves little, so the report says how many it reached before the findings, and warns when that is not all of them. Here every tenant needs a region, and the regions table is empty:
warning: the probe reached 4 of 12 tables (0 of 7 tenant-scoped), with 32 of 168 planned
steps: no probe finding on the other 8 says nothing about them. 8 of them because
private.regions got no synthetic row. See "Not reached" at the end.The end of the report groups the tables it did not reach by the row that was missing, and says what to do about it:
Not reached (8 of 12 tables; fix them, or list them in [probe] skip_tables):
private.regions: could not insert a synthetic row (23514, constraint regions_code_check):
set values it accepts in [probe.values."private.regions"]
8 tables needed its row: public.comments, public.documents, public.invoices,
public.memberships, public.profiles and 3 moreIt also lists the stand-ins it used, where the personas' JWT claims came from, and steps that
failed without saying anything about access. In CI, --min-coverage 90 fails the job when the
probe reaches less than 90% of the tables it could try; tables you list in
[probe] skip_tables don't count against it.
Helping it along
When your schema needs something se-check can't guess, say so in the [probe] section of
se-check.toml:
[probe]
skip_tables = ["public.events"] # leave these alone
claims = { app_metadata = { company = "{tenant}" } } # a claim se-check can't recognize
membership = "insert into public.seats (user_id, tenant_id) values (%(user)s, %(tenant)s)"
privilege_columns = ["clearance"] # besides role, is_admin, plan, ...
[probe.values."public.invoices"] # values it can't make up
status = "draft"Every key is described in Configuration.
Is it safe?
The probe writes, so it is opt-in, and it runs on local databases only (the local stack, CI, a
loopback address) unless you add --probe-remote.
Nothing stays. Everything happens in one transaction that is rolled back, even if se-check crashes. Sequences still advance: ids get gaps.
Only its own rows. It touches only rows it created, by the keys it generated. A synthetic row may point at an existing row of reference data, but only the database sees that row's key: se-check learns whether there was one, nothing else.
No data in the report. It reports outcomes and SQLSTATEs, never values or error messages.
No waiting, no side effects. Statements give up after 5 s, and after 1 s waiting for a lock. Tables whose triggers call
httpordblink, which act outside the transaction, are skipped;pg_netand database webhooks only queue requests in a table, which the rollback empties.
Use it from your AI agent (MCP)
Claude Code, from your project folder:
claude mcp add se-check -- se-check mcp --project "$PWD"That audits the local database. For the hosted one, add --linked and put the password in the
server's environment:
claude mcp add se-check -e SUPABASE_DB_PASSWORD='...' -- se-check mcp --linked --project "$PWD"Keep the default scope: --scope project would write the password into .mcp.json, in your
repository.
Any MCP client (Claude Desktop, Cursor, ...):
{
"mcpServers": {
"se-check": {
"command": "se-check",
"args": ["mcp", "--project", "/path/to/your/supabase/project"]
}
}
}Tool | What it does |
| Runs the checks, returns structured findings with a suggested fix |
| The same, plus the dynamic probe and its coverage; only with |
| Lists the available checks |
Then ask your agent something like "after every migration that touches tables, policies or
functions, run audit_database and fix critical and high findings before you finish".
To let it try the policies on the local stack too, start the server with --probe
(claude mcp add se-check -- se-check mcp --probe --project "$PWD") and ask for
probe_database after policy changes. The server refuses --probe for hosted databases.
The server reads se-check.toml on every call, so edits apply
without a restart. The agent sees accepted findings with their reason, and is told that
accepting a finding is the team's call, not its own.
In CI (GitHub Actions)
Audit the database your migrations build, on every pull request, and get the findings as annotations on the migration that created each object:
name: se-check
on: pull_request
permissions:
contents: read
security-events: write # to upload the SARIF report
jobs:
rls:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
- run: supabase db start # local database, built from supabase/migrations
- uses: devhardesian/se-check@v0
with:
probe: "true" # optional: try the policies too
min-coverage: "90" # optional: fail if the probe reaches less than 90% of tables
- uses: github/codeql-action/upload-sarif@v3
if: always() # upload the annotations even when se-check fails the job
with:
sarif_file: se-check.sarifInput | Default | |
|
| Supabase project folder, the one with |
| Connection string of another database, from a secret; empty audits the project's local database | |
|
| Fail the job on findings at least this severe ( |
|
| Leave out findings below this severity |
|
| Where to write the SARIF report; empty to skip it |
|
|
|
| With | |
| Extra arguments for |
Outside GitHub, se-check audit --sarif report.sarif writes the same file.
Accepting findings
Some findings are fine on purpose: a pricing table everyone may read, say. Record them, with the reason, and they stop cluttering the report and failing CI:
se-check accept permissive-policy public.plans --reason "Pricing is public on the landing page."That writes se-check.toml next to your supabase/ folder: commit it, so the reasons get
reviewed like code. A reason is mandatory, accepted findings are still listed (separately, with
their reason), and entries that stop matching anything are reported so the file doesn't rot.
Configuration: se-check.toml
One file next to your supabase/ folder holds the settings and the accepted findings. Every
key is optional, and command-line options win over the file.
schemas = ["public", "api"] # schemas the Data API exposes (default: public)
api_roles = ["anon", "authenticated"] # roles API requests run as
tenant_columns = ["clinic_id"] # mark tables as tenant-scoped, besides tenant_id & co.
guards = ["check_access"] # functions that check the caller in a way se-check can't read
[[accept]]
check = "permissive-policy"
object = "public.plans"
policy = "plans are public" # optional: only this policy of the table
reason = "Pricing is public on the landing page."
[probe]
skip_tables = ["public.events"]
claims = { app_metadata = { company = "{tenant}" } }
membership = "insert into public.seats (user_id, tenant_id) values (%(user)s, %(tenant)s)"
privilege_columns = ["clearance"]
[probe.values."public.invoices"]
status = "draft"
| |
| Tables the probe leaves alone. They don't count against its coverage, and the report lists them as left out. |
| Values for columns se-check can't make up, written as text: Postgres casts them to the column's type. |
| Extra JWT claims for every logged-in persona, applied last. |
| SQL run for alice and bob when the catalog can't show how users join a tenant, with |
| Columns that decide privileges, besides |
Command-line reference
Command | |
| Audit a database and print the findings |
| Record a finding as fine on purpose ( |
| List the checks |
| Start the MCP server over stdio |
Option | Default | |
| current folder | Supabase project folder (the one with |
|
| Settings and accepted findings |
| Audit the hosted project linked with | |
|
| Connection string of any Postgres database |
|
| Schemas exposed through the Data API (repeatable) |
|
| Roles API requests run as (repeatable) |
| built-in list | Extra column marking a table as tenant-scoped (repeatable) |
| Function that checks the caller in a way se-check cannot read, e.g. written in C (repeatable) | |
| all | Run only these checks (repeatable, |
|
| Hide less severe findings ( |
|
| Exit with status 1 when a finding is at least this severe; |
| Also run the dynamic probe ( | |
| Let | |
| With | |
|
|
|
| Also write a SARIF report to this file ( | |
| Plain text output; |
Exit status | |
0 | No finding at |
1 | A finding at |
2 | Usage error, broken |
Environment: SE_CHECK_DSN (connection string), SUPABASE_DB_PASSWORD (password for
--linked, the variable the Supabase CLI reads too), NO_COLOR.
Try it on a deliberately vulnerable app
examples/vulnerable-app is a small multi-tenant schema in which
every object is tagged VULN or SAFE. Spin it up and audit it:
docker compose up -d
se-check audit --dsn postgresql://postgres:postgres@localhost:54329/postgresNo Docker? uv run python scripts/playground.py starts a throwaway Postgres with the same
schema and opens a menu: run the audit and the probe with one keystroke, and read what each
part of the command means, how to run it yourself, how to point it at your own database and how
to hook it up to an AI agent. Quitting deletes the database.
Safety and privacy
Metadata only. se-check reads the system catalog (tables, grants, policies, function definitions), never the contents of your tables. Findings name objects, not data.
Read-only. Every query of the audit runs in a read-only transaction with a 30 s statement timeout. Server-side prepared statements are off, so Supabase's transaction pooler works. The one exception is
--probe, which writes synthetic rows in a transaction it always rolls back: see Is it safe?.MCP. The connection string comes from the server configuration, never from the model.
audit_databaseandlist_checksare annotated read-only;probe_database, there only with--probeon a local database, says it writes. Policy expressions quoted in findings come from the database: the server tells the agent to treat them as data, not instructions.
Limitations
The probe tries tables, not functions. It does not call
/rpcfunctions yet.The probe does not read CHECK constraints. A made-up value a CHECK rejects means no synthetic row for that table (the coverage says so, and what to set in
[probe.values]); an escalation value it rejects shows up as an inconclusive step.One synthetic row per table and tenant. A table whose CHECK compares two references to the same table (
a_id <> b_id) gets no row, and tables without a primary key get rows but no steps.Claims and privilege columns are recognized by name. The probe infers only the JWT claims that name a tenant (others come from your access token hook or
[probe] claims), and privilege columns by their English names (add yours withprivilege_columns).Caller checks are recognized by name:
auth.uid(),auth.jwt(), helpers that read them and--guardfunctions. A function can callauth.uid()and still get the logic wrong.Helpers are followed one level deep for
user_metadata: a policy → helper → helper chain that reads it is not caught yet.
Development
uv sync
uv run pytest # starts a throwaway Postgres via pgserver, or uses $SE_CHECK_TEST_DSN
uv run ruff check . && uv run ruff format --check .Database tests need a Postgres superuser on a disposable server: pgserver (installed with the
dev dependencies on Python ≤ 3.12) or the server in SE_CHECK_TEST_DSN, which CI uses. Without
either they are skipped.
Checks are pure functions over a catalog snapshot (src/se_check/catalog.py), so most tests
need no database. A new check needs unit tests in tests/test_checks*.py and VULN/SAFE
cases in the example app, listed in tests/test_integration.py: that test fails on false
positives as well as misses. The probe is split the same way: its planner, claims and rules are
pure (tests/test_probe_plan.py, tests/test_probe_claims.py, tests/test_checks_probe.py),
and only probe/execute.py touches the database (tests/test_probe_integration.py).
What changed in each version is in CHANGELOG.md.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
Deterministic safety, correctness & cost gate that vets Postgres SQL before your AI agent runs it.
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Guard AI agents' PostgreSQL/MySQL access via MCP: SQL audit, auth, masking, write approval
Your Supabase account in natural language: run SQL, apply migrations, manage tables, storage, edge f
Related MCP Servers
- AlicenseAqualityCmaintenanceMCP server that lets AI coding agents (Claude Code, Cursor, Cline) audit Supabase projects for security misconfigurations AND apply the fixes — without leaving the agent. Tools: audit_project, list_findings, preview_fix (BEGIN/ROLLBACK safety), apply_fix (with confirmation), apply_all_fixes (transactional bulk). Closes the audit-fix loop entirely in the agent — other Supabase scanners only report.52 npm1MIT
- AlicenseAqualityDmaintenanceAudits PostgreSQL/Supabase schemas for security issues like missing RLS, permissive policies, and sensitive data exposure during AI conversations.3MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to safely query PostgreSQL by intercepting and auditing every generated SQL statement before it reaches the database, blocking destructive commands like DROP/TRUNCATE, unbounded DELETE/UPDATE statements, stacked queries, and SQL injection probes. It exposes tools for validating query safety and enforcing a strict read-only (SELECT/EXPLAIN) policy.MIT
- AlicenseNot gradedqualityBmaintenanceLets coding agents ask whether a Supabase migration is safe to merge: it replays the base branch and working tree into throwaway databases, reports only the findings the change introduces, and proves RLS leaks by querying as anon. Tools are read-only and take profile names instead of connection strings.14 npmMIT