RLS and you
Short version: every table in your modusbrain’spublic schema needs Row Level
Security enabled. If one doesn’t, modusbrain doctor now fails, not warns, and the
process exits 1.
This guide explains why, what to do when you hit the check, and the escape hatch
for the cases where you really do want a table to stay readable by the anon key.
Why RLS matters
Supabase exposes everything in thepublic schema via PostgREST. Whatever’s
there is reachable by the anon key, which is a client-side secret by design.
If RLS is off on a public table, the anon key can read it. On anything sensitive
(auth tokens, chat history, financial data) that’s an exfiltration vector, not
a footgun.
modusbrain’s service-role connection holds BYPASSRLS, so enabling RLS without
policies does NOT break modusbrain itself. It just blocks the anon key’s default
read. That’s the security posture: deny-by-default to anon, full access for
the service role.
What to do when doctor fails
Doctor’s message names every table missing RLS and gives you aALTER TABLE
line per table:
modusbrain doctor. Done.
v0.26.7 — auto-RLS event trigger and one-time backfill
Starting in v0.26.7 (migration v35), modusbrain ships two changes that close the gap where a table could exist in yourpublic schema without RLS for any
amount of time at all.
1. The event trigger. A Postgres DDL event trigger named
auto_rls_on_create_table runs ALTER TABLE … ENABLE ROW LEVEL SECURITY
on every newly created public.* table. It covers CREATE TABLE,
CREATE TABLE AS … SELECT, and SELECT … INTO — every syntax Postgres
reports as a table-creation command. Tables created by modusbrain itself, by
your other apps sharing the same Supabase project (Baku, Hermes, anything),
or by a human running raw SQL all get RLS enabled the moment they exist.
Non-public schemas (auth, storage, realtime, etc.) are explicitly
ignored — Supabase manages those, and we should not touch them.
2. The one-time backfill. When you upgrade to v0.26.7, the migration
walks every existing public.* base table whose RLS is off and whose comment
doesn’t carry the MODUSBRAIN:RLS_EXEMPT exemption (see below) and enables RLS
on each. After the upgrade, modusbrain doctor’s rls check should be a no-op
on every brain.
Breaking change: read this before upgrading
If you have public tables that are intentionally RLS-off and you want them to stay that way, you MUST add theMODUSBRAIN:RLS_EXEMPT comment before
running modusbrain upgrade to v0.26.7. The backfill flips RLS on for any public
table that doesn’t carry the exact comment contract documented below. There
is no --dry-run flag on the migration.
The minimum cost of getting this wrong is one round-trip: the operator runs
the SQL to enable RLS on a table that should have been exempt, then
ALTER TABLE … DISABLE ROW LEVEL SECURITY and adds the exempt comment to
prevent a re-flip on a later doctor run. No data is lost.
Cross-app implications
If a non-modusbrain app (Baku, Hermes, a script you wrote, anything) creates tables in the same Supabase project, the trigger will enable RLS on those tables too. Two ways to handle that:- The app’s connection role has BYPASSRLS (e.g. it’s also using the
postgresrole). Newly created tables get RLS on but the app reads/writes freely because BYPASSRLS bypasses policies entirely. - The app’s role does NOT have BYPASSRLS. Then the app needs to add a
CREATE POLICYimmediately after creating the table, granting itself the read/write access it needs. The trigger does NOT add policies — it only enables RLS, leaving the deny-by-default posture in place until the app’s policy lands.
What if the trigger gets dropped?
modusbrain doctor includes a new rls_event_trigger check that verifies the
trigger is installed and enabled. If you drop it manually for any reason
(debugging, migration testing, anything), doctor warns and gives you the
recovery command:
DROP EVENT TRIGGER IF EXISTS
and recreates cleanly.
Why no FORCE ROW LEVEL SECURITY?
Postgres has two RLS dials.ENABLE blocks anon/authenticated; FORCE also
blocks the table OWNER unless they hold BYPASSRLS. We use ENABLE only,
matching the posture in src/schema.sql, migrations v24, and v29. FORCE
would lock non-BYPASSRLS apps out of their own freshly-created tables (the
trigger function inherits the caller’s role, not the modusbrain role) — which
defeats the cross-app coexistence story above. If you want defense-in-depth
FORCE on a specific modusbrain-owned table, add it explicitly in your own
migration; modusbrain’s auto-RLS does not opt you in by default.
The 1% case: deliberate exemption
Sometimes a public table is supposed to be readable by the anon key. An analytics view backing a public dashboard. A read-only reference table. A plugin that ships its own frontend and intentionally uses the anon key for reads. modusbrain has an escape hatch for these. It is deliberately painful to set up. That is the feature.The format
- The comment value MUST start with
MODUSBRAIN:RLS_EXEMPT(case-sensitive). - It MUST include
reason=followed by at least 4 characters of justification. - No other prefix, no checkbox in a config file, no environment variable. Only a Postgres table comment counts.
- If RLS is also off on the table (which it must be for the anon key to
actually read), you also need
ALTER TABLE ... DISABLE ROW LEVEL SECURITY;explicitly. Disabling alone is not enough; the comment is what tells doctor this is intentional.
Example
modusbrain doctor reports:
modusbrain doctor.
Why SQL and not a CLI subcommand
modusbrain does NOT ship amodusbrain rls-exempt add <table> command. A CLI command
would make it easy for an agent to silently open a table to anon reads. The
comment-in-psql requirement forces the operator to type the justification
in SQL, which is:
- Visible in shell history.
- Visible in a git-tracked schema dump.
- Visible in
pg_dumpoutput the next time you restore. - Visible in
modusbrain doctoroutput on every run.
Auditing exemptions later
To see every exemption in the current DB:Removing an exemption
Just drop the comment and re-enable RLS:modusbrain doctor stops listing the table as exempt and goes back to checking
it like any other.
PGLite
If you’re on PGLite (the zero-config default), doctor skips this check entirely: PGLite is embedded, single-user, and has no PostgREST in front of it. The public-schema-exposure risk doesn’t exist. You’ll see:Self-hosted Postgres
If you’re running Postgres without PostgREST in front, the anon-key exposure doesn’t apply. But modusbrain still fails the check on missing RLS, because:- The framing is “RLS on all public tables” is a modusbrain security invariant, not a Supabase-specific workaround.
- The
ALTER TABLE ... ENABLE RLSfix is harmless on any Postgres: it only constrains non-bypass roles, which modusbrain doesn’t use. - If you ever put PostgREST or a similar tool in front later, the guard is already in place.