New table, no policy: the default-open window
A Supabase table without RLS is readable by anyone holding your public key, on every API surface. What the window exposes, and how to close it structurally.
Every new Supabase table starts open: no row-level security flag, client grants already provisioned. This article traces what that means through each surface it exposes, and shows the structural habits — templates, catalog assertions in CI — that close the window before features ship into it.
There is a specific moment in every Supabase project's life that deserves more ceremony than it gets: create table. From that instant until someone adds an enable row level security line, the table's rows are reachable by anyone on earth holding your project's public key — which is everyone using your app, plus everyone who never bothered making an account. No warning fires. The migration succeeds; the feature works; the dashboard shows a healthy new table.
This article treats that window as the operational object it is: how wide it actually opens, which surfaces it exposes simultaneously, why nothing announces it, and what closing it structurally — rather than vigilantly — looks like.
The default state is open, by design
Two platform defaults combine to produce the window:
Row security is opt-in per table. Postgres creates tables with relrowsecurity = false; the row security documentation describes enablement as an explicit act. Until then, policies are irrelevant — none exist to evaluate.
Client roles hold table grants by default. A provisioned Supabase project grants the anon and authenticated roles privileges on tables in the exposed schema as they appear (the arrangement behind Supabase's roles guide). That is what makes PostgREST work out of the box for properly protected tables — and it also means a table with neither RLS nor explicit grants needs zero misconfiguration on your part to become world-readable. The defaults are doing exactly what defaults do: optimizing for things working immediately.
The composite result, verified in plain SQL during this article's preparation:
create table notifications (
id bigint generated always as identity primary key,
user_id uuid not null,
body text not null,
created_at timestamptz not null default now()
);
-- The check nobody has run yet:
select c.relname, c.relrowsecurity as rls_enabled
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relname = 'notifications';
relname | rls_enabled
---------------+-------------
notifications | f
That single f is the whole finding. Everything downstream follows from it.
What the open window costs while it stands
It's tempting to file the naked table under "todo before launch" and move on — so it's worth pricing what the todo actually represents while it waits.
Enumeration is free. A readable table is a downloadable one. Pagination and filtering are caller-controlled on the REST surface, which means an anonymous visitor can walk the entire row set at whatever pace your infrastructure allows, shaped however they like. The cost of that isn't theoretical; it's the difference between a data breach being an incident someone must cause and an archive anyone may collect.
Compliance claims invert. If your product tells customers or auditors that user data is access-controlled, every table without RLS is a sentence in that claim that isn't true — regardless of whether anyone has found it yet. Post-incident, "the table was there for weeks" reads worse than "the table was exposed briefly," and the catalog makes the duration provable.
The window widens itself. Every feature built against the unprotected table accumulates assumptions about its openness: client code that fetches freely, tests that expect rows back, internal tooling reading through the API. Each week of exposure raises the cost of closing the window, because closing now breaks things that grew into it.
None of these costs announce themselves at deploy time. All of them compound quietly. Which is why the structural fixes later in this article matter more than any individual cleanup: they shrink the duration of future windows to minutes rather than quarters.
What the window exposes, surface by surface
"Open" isn't one door — it's several, opened simultaneously by the same missing flag:
| Surface | Behavior with RLS off | Notes |
|---|---|---|
| REST API (PostgREST) | Full read of all columns, for any caller with the anon key | Filtering, ordering, pagination all available to the caller |
| Generated clients | Identical — they speak the REST API | supabase.from('notifications').select() just works |
| GraphQL | Same underlying privilege model | Tables in the exposed schema appear in the graph |
| Direct SQL paths | Governed by role grants instead | Backend scripts see everything regardless |
The REST row deserves unpacking because it defines the blast radius. An anonymous GET against /rest/v1/notifications returns data shaped however the caller asks — column selection, filters, pagination parameters are caller-controlled. If the table holds a thousand users' rows, that's a thousand-row download with no authentication beyond the public key. If rows contain foreign keys into sensitive neighbors, those identifiers enumerate the next targets. This is the exposure class our anon-table-readable rule detects from outside, and reading your own public surface shows the audit technique applied to a real-shaped app.
One boundary clarifies the map: Storage and Realtime have their own authorization machinery, separate from table RLS — a table being open does not directly open buckets or channels. Those surfaces carry their own drift patterns, treated separately in the storage exposure rule.
Why silence surrounds the window
Three properties of the failure make it uniquely persistent:
It looks like success everywhere you look. The deploy pipeline passed. Client queries return data. No error appears in any log — there is no error, only absence of restriction. Contrast a locked-out table (RLS on, no policies), where the symptom is loud empty results that force investigation. Open tables fail toward working, which is why they survive code review, staging, launch week, and sometimes audits.
The dashboard compresses the signal away. A table list showing dozens of tables doesn't render per-table RLS flags prominently; "is this one protected?" requires opening each table's page. Humans scanning lists miss flags; so do humans scanning schemas. The information exists in the catalog but not in the workflow.
Fixing it the naive way feels risky. Someone eventually notices, enables RLS immediately — and the feature breaks loudly (default deny), possibly in production. The team reverts. Now the table is known-broken-when-protected, and the org develops folklore that RLS is dangerous. The correct sequence — write the policy set first, enable in the same migration, test both directions — is straightforward but must be learned deliberately, because nothing in the incident teaches it.
Closing the window structurally
Vigilance doesn't scale; structure does. Three layers, in ascending order of permanence:
Layer 1: a standing migration template
Make protection part of table creation rather than a follow-up ticket. The template lives wherever migrations live, and its rule is absolute — no migration ends with a public-schema table that lacks both lines:
create table notifications (
id bigint generated always as identity primary key,
user_id uuid not null,
body text not null,
created_at timestamptz not null default now()
);
alter table notifications enable row level security;
-- Policies for THIS table's access pattern (write them now, not later):
create policy "notifications_select_own"
on notifications for select to authenticated
using ((select auth.uid()) = user_id);
Note what the template enforces philosophically: the policy conversation happens while writing the feature, when access intent is freshest, not weeks later during an audit. If the honest answer is "this table has no client-facing pattern yet," ship it enabled with zero policies — default deny is a perfectly good interim posture, and the feature surfaces will announce their needs by breaking safely.
The habit compounds beyond safety. Policies written at table creation encode access decisions while they're still cheap to change; policies written after launch encode whatever the shipped feature accidentally assumed. Teams that adopt the template report the same shift teams report from test-first habits — not just fewer holes, but features whose access model was designed rather than discovered.
Layer 2: the two-line CI assertion
Templates depend on people remembering them. The catalog doesn't. Append this to every migration run — or wire it into CI as a post-migration gate — and let arithmetic replace memory:
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
and not c.relrowsecurity;
Empty result: pass. Any row: a table shipping naked — fail the build, name the table. This one assertion mechanically eliminates Pattern One of migration drift (recreates included), costs milliseconds, and requires no judgment calls about severity. It is, deliberately, the same query from the manual checklist — automation of something you could always do by hand.
In CI form, the gate is a few lines attached to any job that already applies migrations:
- name: Assert every public table has RLS enabled
run: |
psql "$DATABASE_URL" -t -c "
select c.relname from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
and not c.relrowsecurity;" > naked.txt
if [ -s naked.txt ]; then
echo "Tables without RLS:"; cat naked.txt; exit 1
fi
The failure mode of this gate is instructive in itself: it cannot be nagged into compliance. Either the migration grows its enable row level security line or the build stays red. Teams report the same arc with it that teams report with type checkers — two weeks of friction, then a generation of contributors who never see the problem because the pipeline won't let it exist.
One refinement covers the edge cases: extend the query to also flag tables where RLS is enabled but no policies exist yet, as a warning rather than a failure. That second signal catches the tables stuck in interim state for months — protected but unreachable, waiting on policy work someone deprioritized and everyone forgot.
Layer 3: outside verification
Layers 1–2 guard the pipeline. Production accumulates other kinds of change — dashboard edits, restores, hotfix branches, the migration that ran from a laptop. Periodic verification from the outside closes that gap, because the question "can an anonymous stranger read this?" has exactly one authoritative answer, and it comes from asking as one:
curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/notifications" \
-H "apikey: $ANON_KEY"
Reading the response is mechanical:
| Response | Meaning |
|---|---|
[] | Protected (or genuinely empty) — either way, no exposure via this table |
[{"id":1,"user_id":"…","body":"…"}] | Open window, live right now, columns included |
404 / error object | Table not exposed at all (moved schema, removed from API) — investigate separately |
Framed explicitly: this is an audit of your own project using only your own public key — the same request any browser makes. Running it against your full table list takes minutes and answers the exposure question with production truth rather than migration intent. That's also precisely what probe-style automation does continuously (what the free scan verifies): the pipeline can only certify what flows through it, and the internet can only be trusted to check.
The three states, verified side by side
Worth internalizing as a table — these are the only three states a table can be in, and their observable behaviors differ in ways testing should assert:
| State | Anonymous REST read | Authenticated read (no relation) | Symptom if broken |
|---|---|---|---|
| RLS off | Returns all rows | Returns all rows | None — this is the defect |
| RLS on, zero policies | Empty | Empty | Feature broken loudly (safe direction) |
| RLS on, correct policies | Empty | Only permitted rows | None, when correct |
Asserting all three rows for a new table takes four impersonated queries (the harness from our testing guide). Teams that add those assertions alongside Layer 1's template get regression detection on authorization for free — the table cannot silently migrate between rows of that table without a failing test.
One subtlety in the middle row deserves emphasis: "RLS on, zero policies" returns empty results for every client-facing caller, which makes it the correct interim state while a feature's access rules are being designed — and a suspicious permanent state afterwards. If a table has sat in row two for months, either the feature died (drop the table) or the policies were never written (finish the job). Both are decisions; only one of them has been made.
The top row deserves the opposite treatment: it is not a ticket, it is an open door. Enable RLS first (breaking reads safely, if that is what it takes), then write the policy set, then restore the feature. A window's remaining lifetime should be measured in deployment cycles rather than sprints — and the structural layers above exist precisely so that most windows never open at all.
Common questions
Does enabling RLS with no policies break my backend scripts?
Only scripts connecting through client-facing roles. Server code running as the table owner or service_role bypasses policies entirely and keeps working unchanged — which is precisely why the interim "enabled, zero policies" state is safe to ship while you design the client-facing rules.
Is GraphQL exposure identical to REST here?
For authorization purposes yes — GraphQL sits on the same database privileges and row security. A table absent from REST due to RLS is equally protected in the graph; a naked table appears in both. Audit once, cover both.
We don't use anon access at all. Are we still exposed?
Yes — "not using anon" is a product decision, not a technical barrier. The key ships in every bundle regardless, and any table without RLS serves it. Disabling anonymous features happens in policies; the key itself is permanently public.
Staging passed this check. Doesn't that prove production is fine?
Only if production's schema arrived exclusively through the same pipeline. Restores, dashboard SQL editors, and manual hotfixes all bypass CI, and each can flip an RLS flag without any record in the repo. Staging parity is a property you verify, not one you inherit — which is why the outside check runs against production, where the truth actually lives.
What about tables in non-public schemas?
PostgREST only exposes configured schemas (public by default). Moving genuinely internal tables to unexposed schemas is legitimate defense-in-depth — but keep enabling RLS anyway, since exposure configuration is itself state that can drift.
Find your own naked tables right now: run the free scan — paste your app URL, and see exactly what an anonymous caller can reach, with remediation SQL for each finding.
RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.