Your Supabase Anon Key Is Public by Design: RLS Is the Real Security Boundary
In Supabase the database is the API, so Row Level Security — not API keys — is the real security boundary. A worked policy example, common footguns, and the per-row performance trade-off.
18 Jan 2026, 06:50 UTC

Open the browser devtools on any Supabase app and you'll find a database key sitting in plain sight. That's the anon key, and it's supposed to be there: Supabase exposes Postgres directly to browsers through auto-generated APIs, so the key ships inside your JavaScript by design. The uncomfortable consequence is that API keys can't be your security boundary. That job belongs to Postgres Row Level Security (RLS), a feature that filters individual rows per query based on policies you write in SQL. If a table is reachable through the API and RLS is off, anyone with the anon key can read and write every row in it.
The takeaway up front: enable RLS on every table the API can reach, write policies around the signed-in user's ID, and treat those policies as your application's authorization layer — not a nice-to-have you'll add before launch someday.
The anon key is public, so the database has to decide
Supabase projects have two kinds of keys. The anon key is publishable: it identifies your project, not a user, and it's embedded in every browser client. The service_role key is secret: it bypasses RLS entirely and belongs only on servers you control.
When a user signs in, Supabase issues a JWT — a signed token that identifies them — and injects it into the database session for every API request. Policies can then ask "who is calling?" with helpers like auth.uid(), which returns the signed-in user's ID (or null for anonymous callers). Because the default architecture has no server middleware where you'd normally put authorization checks, the database has to do it. That's not a workaround; it's the intended design.
One policy that filters reads and validates writes
RLS is standard Postgres (available since 9.5, so every current Supabase project has it). You enable it per table and attach policies. Here's a complete example for a todos table — run it in the Supabase dashboard's SQL Editor or a psql session, as a role that owns the table (postgres works), substituting your own table and column names:
create table public.todos (
id bigint generated always as identity primary key,
owner uuid not null references auth.users (id),
task text not null,
is_done boolean not null default false
);
alter table public.todos enable row level security;
create policy "owners manage their todos"
on public.todos
for all
using (auth.uid() = owner)
with check (auth.uid() = owner);
create index on public.todos (owner);The two clauses do different jobs. USING filters which existing rows a query can see or modify — a signed-in user's SELECT silently becomes WHERE owner = auth.uid(). WITH CHECK validates new rows: an INSERT or UPDATE that would leave a row the caller doesn't own is rejected with a row-level security error. One statement, both directions enforced.
Expected behavior, verifiable from two browser sessions signed in as different users: each sees only their own todos, an anonymous client sees none, and an insert with someone else's user ID in owner fails. One nuance: table owners bypass RLS in raw SQL sessions by default, so test authorization through the client SDK with real JWTs, not the SQL editor. Also confirm helper details like auth.uid() against the current Supabase docs for your project — dashboard defaults and helpers have evolved over time, even though the Postgres mechanics above are stable.
Three ways teams get burned
- Silent denial. RLS enabled with no policies denies every row to every non-owner role. That's a safe default, but it's also the answer to "why does my query return nothing?" more often than people expect — check policies before debugging client code.
- Recursive policies. A policy that queries the table it protects — say, a team-membership check selecting from the same table — fails with an infinite-recursion error. The standard fix is a SECURITY DEFINER function (one that runs with its owner's privileges) to perform the lookup.
- The service_role key. It skips RLS completely, so a single leaked service key defeats the entire model. Keep it in server-side environment variables, never in client bundles. The flip side cuts the same way: disabling RLS or dropping a policy re-exposes a table instantly, which is exactly why that revert is dangerous to do casually.
The trade-off: policies run per row
Policy expressions are evaluated as part of every query against candidate rows. Simple equality like auth.uid() = owner is cheap, especially with an index on owner — which is why the example creates one. Naive policies built from subqueries or joins can drag down every query touching the table. Two habits keep this healthy: keep policy expressions simple, and check the plan for a representative query with explain select * from public.todos where owner = '<a-real-user-uuid>'; to confirm it uses the index rather than a sequential scan.
The other cost is organizational: authorization logic now lives in SQL. That's a feature — one enforcement point instead of scattered middleware — but it means policies belong in version-controlled migrations and get reviewed like any other code.
A pre-ship checklist
Before exposing any Supabase table to a browser:
- Confirm RLS is on everywhere the API can reach:
select tablename, rowsecurity from pg_tables where schemaname = 'public';— every row should read true. - Confirm each table has at least one policy, and that an anonymous client gets zero rows where that's intended.
- Sign in as two test users and verify each sees only their own data; attempt a cross-owner write and confirm it's rejected.
- Run EXPLAIN on a representative query and confirm the policy column's index is used.
- Search your client code and git history for the service_role key.
Do this once per project and it becomes boring — which is exactly what you want from a security boundary.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.