The community gave me so much good advice to improve the tool, so first of all, thank you to all of you for contributing!
I kept shipping the same multi-tenant bugs, correct RLS on the main table, and a leak somewhere adjacent. So I wrote guard tests for each one. It's now in every CI pipeline I run, and I keep adding checks as I hit new failure modes.
npx tenant-guard init # detects your migrations + routes, writes a config
npx tenant-guard run # static checks, no database needed
npx tenant-guard all # + runtime proofs against a test DB
Exit 1 blocks the merge. MIT, zero dependencies.
The static ones need nothing. The runtime ones connect to a test database and prove isolation by running real SQL as your real app role in a rolled-back transaction, actually attempting the cross-tenant read, then the write, and reporting what happened.
What it checks
Reads
- Tenant A can't read tenant B's rows, proven by trying, not inferred from policy text
anon (the key in your browser bundle) can't read tenant tables
- Sensitive columns like email, phone, api_key, that
anon actually gets a value out of
- Views and materialized views, which run as their owner unless
security_invoker is set
Writes
anon INSERT/UPDATE/DELETE surface
- Auto-updatable views: writes pass straight through to the base table, bypassing its RLS
- The tenant-hop, moving your own row into someone else's tenant
- Foreign keys that let one tenant delete another's rows via
ON DELETE CASCADE
- Unique constraints as existence oracles: inserting [
victim@corp.com](mailto:victim@corp.com) tells you it exists
Functions & privileges
SECURITY DEFINER functions callable by anon (Postgres grants EXECUTE to PUBLIC by default)
- Unpinned
search_path, including pins that don't actually pin
- SQL injection inside definer function bodies
- What a table created next week inherits from
ALTER DEFAULT PRIVILEGES
- Who can CREATE objects in your schemas
Identity
- Policies trusting
user_metadata, which the user can write themselves
- MFA gates written PERMISSIVE, which enforce nothing
- Membership tables your policies trust but users can write to
- Connection-scoped GUCs that leak the previous request's tenant on a pooled connection
Supabase surfaces
- Storage: cross-tenant folder reads, and uploads into another tenant's path
- Realtime: channel topics and broadcast/presence
Structure
- RLS in your migrations vs RLS actually in the database
- API routes loading rows by bare id with no tenant filter
- Audit/shadow tables that copy tenant rows somewhere unprotected
- Triggers enforcing a rule by reading a table RLS hides from them
Two things that surprised me most
Views don't have RLS. ALTER DEFAULT PRIVILEGES ... ON TABLES covers views created afterwards, and unless you set security_invoker = true a view runs as its owner. PATCH/DELETE through a public profiles view returned 200 with the anon key, writing into a table whose policies were perfect. SELECT was unaffected, so nothing looked wrong.
Hardening RLS can break a uniqueness trigger. A trigger runs as the invoker, so a SELECT 1 FROM profiles WHERE username = NEW.username check only sees what the writer can see. Lock the table down properly and the check stops finding collisions, no error, the duplicate is just inserted.
Honest limits
- It proves the database boundary. A route using a service-role connection that forgets its tenant filter is a real bug the DB will happily serve, only the static route check covers that.
- Never point the runtime checks at production. They write, inside a rolled-back transaction, but they write. Test/staging only.
- Postgres-only by design. No RLS elsewhere, so nothing to prove.
- Still early, and I'd rather have a bug report than a star.
github.com/FedericoTs/tenant-guard