What Row Level Security actually is
Row Level Security is a Postgres feature, not a Supabase invention, that lets you attach a boolean expression to every SELECT, INSERT, UPDATE, and DELETE on a table. When a query runs, Postgres evaluates the expression once per candidate row and silently drops any row where it returns false. From the client's perspective those rows do not exist: no 403, no error, just an empty result set. Supabase turns this feature into a security boundary by making RLS mandatory for any table exposed through its auto-generated Data API. That is the entire reason the publishable anon key is safe to ship inside a browser bundle. The key identifies your project and lets the caller reach PostgREST, but every row the caller sees still has to pass a policy tied to their JWT. If you disable RLS on a table exposed through the Data API, that table becomes fully public to anyone with your project URL. There is no partial mode: RLS is either on and enforced or off and wide open.
The mental model: policies are extra WHERE clauses
The single most useful way to think about RLS is that every policy becomes an extra AND clause appended to the query's WHERE. If your policy is USING (auth.uid() = user_id), then SELECT * FROM notes silently becomes SELECT * FROM notes WHERE auth.uid() = user_id. That framing kills most of the confusion beginners hit. Indexing works the same as any other WHERE column, joins compose the same way, and EXPLAIN shows the rewritten query verbatim. It also explains why policies never leak data on their own: if the expression is false, the row is filtered before it ever leaves Postgres.
The three policies that cover most real apps
- Owner-only: USING (auth.uid() = user_id) — the classic per-user table for notes, tasks, uploads.
- Public read, owner write: SELECT policy USING (true) plus UPDATE and DELETE policies USING (auth.uid() = user_id) — for profiles, comments, published posts.
- Team membership: USING (EXISTS (SELECT 1 FROM memberships m WHERE m.team_id = row.team_id AND m.user_id = auth.uid())) — the shape behind almost every B2B SaaS.
Minimum viable policy, end to end
Four statements, in this order, every time. Create the table, GRANT the roles your policies target, ENABLE ROW LEVEL SECURITY, then CREATE POLICY. The GRANT is the piece newcomers skip most often, and its absence produces confusing permission-denied errors that look like RLS bugs but are actually the Data API failing before RLS even runs.
- CREATE TABLE public.notes (id uuid primary key default gen_random_uuid(), user_id uuid not null, body text);
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.notes TO authenticated;
- GRANT ALL ON public.notes TO service_role;
- ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
- CREATE POLICY "own notes" ON public.notes FOR ALL TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
USING vs WITH CHECK
USING controls which rows a query can see or touch. WITH CHECK controls which rows an INSERT or UPDATE is allowed to write. They are separate on purpose. A policy might let you read every row in a table (USING true) but only let you insert rows where user_id equals your auth.uid() (WITH CHECK auth.uid() = user_id). Forgetting WITH CHECK is a common vulnerability: users can update rows they can see and quietly reassign ownership by writing a different user_id. When in doubt, set both clauses to the same predicate.
Roles: anon, authenticated, service_role
Every policy is attached to one or more Postgres roles. Supabase ships three: anon (unauthenticated visitors), authenticated (any signed-in user), and service_role (used only by server-side code with the secret key, which bypasses RLS entirely). Write policies against authenticated for user data and against anon only for genuinely public reads. Never grant anon INSERT or UPDATE unless you have a specific public-write use case like an anonymous feedback form, and even then rate-limit at the edge. The service role key is a nuclear option: it bypasses every policy, so it belongs only in server-side code where you have already verified the caller.
Common gotchas
Policies are ORed, not merged. A permissive SELECT policy plus a restrictive one still returns rows the permissive one allows. JWT claims you set on the client are not trusted; RLS reads auth.uid() and auth.jwt() from the verified access token that Supabase's PostgREST layer parses server-side. Recursive policies (a table whose policy queries the same table) can hang; use SECURITY DEFINER helper functions to break the cycle. And realtime subscriptions honor RLS: if a policy filters out a change, the subscribed client never sees it, which is a feature, but surprising the first time.
Testing policies before you ship
The dashboard's SQL editor has a role switcher that lets you run any query as anon or as a specific user. Use it: write the policy, then run the exact SELECT your client will run, first as the row's owner and then as a different user. You want the first to return the row and the second to return nothing. Automate this in CI with pgTAP or a small integration test that logs in as two seed users and asserts cross-tenant reads return zero rows. Ten lines of test code prevents most of the RLS incidents that hit production.
Debugging a policy that is not doing what you expect
When a policy misbehaves the first move is to reproduce the failing query in the SQL editor under the same role, then wrap it in EXPLAIN to see the rewritten plan. Postgres shows the policy expression inline as an extra filter, which makes it obvious whether auth.uid() is resolving to what you expected. Common surprises: the JWT was refreshed and the sub claim now points at a different user; the policy references a column that is null on the row you were testing; or a helper function was created without SECURITY DEFINER so it runs under the caller and hits its own RLS recursion. Log the value of auth.uid() at the top of a SELECT to confirm the identity, then narrow the predicate one clause at a time until the row appears.
Performance: the indexes you actually need
Because policies fold into WHERE, they use the same indexes as any other predicate. That means a per-user table should have an index on user_id, a team table on team_id, and any policy that joins to a memberships table needs an index on the join column of that helper table too. The planner will happily do a sequential scan of a million-row table if the index is missing, and the resulting slow queries look like a Supabase problem when they are really a schema problem. Run EXPLAIN ANALYZE on your hot policies during load testing, watch for Seq Scan on tables above a few thousand rows, and add the index. Composite indexes (user_id, created_at) speed up the common 'list my rows in date order' query even more.
Where RLS ends and application code begins
RLS is a row-visibility firewall, not a business-logic engine. It cannot express rules like "a user can create at most ten notes per day" or "orders can only be canceled while status is pending" cleanly, because those depend on aggregates or state machines rather than a single row predicate. Push those checks into an Edge Function or a database trigger that runs inside a transaction, and let RLS keep doing what it is good at: deciding which rows the caller is allowed to see or touch at all. The combination is powerful — RLS gates the door, application code enforces the workflow — and each layer is small enough to reason about on its own.