Skip to main content
All guides
Supabase basics

Supabase RLS explained: writing your first Row Level Security policy

RLS is the reason a Supabase publishable key is safe to ship to the browser. Here is exactly how it decides whether a query returns your row.

Last updated July 18, 2026

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.

FAQ

Is RLS enough security on its own?
For row-level access, yes. But RLS only decides which rows a query touches. You still need input validation to reject malformed payloads, rate limits on writes to prevent abuse, careful GRANT choices so the anon role cannot reach tables it should not, and normal application-level checks for business rules that do not map cleanly to a single row predicate.
Does RLS slow down queries?
Only as much as adding the policy expression to your WHERE clause would. The Postgres planner rewrites the query and uses whatever indexes cover the policy columns, so indexing the columns you reference (usually user_id or team_id) keeps things fast. Policies that call SECURITY DEFINER helpers or subqueries against unindexed tables are where teams hit real slowdowns.
Can I disable RLS temporarily for a migration?
You can with ALTER TABLE ... DISABLE ROW LEVEL SECURITY, but the moment you do, the Data API exposes every row on that table to anyone with your project URL. Prefer running migrations from a server function that uses the service role key, which bypasses RLS without opening a hole. If you must toggle RLS off, do it inside a transaction and re-enable in the same transaction.
How do I write a policy for admins?
Store roles in a separate user_roles table with (user_id, role) rows, not on the profiles table. Create a SECURITY DEFINER function has_role(uid uuid, r app_role) returning boolean, and reference it from policies as USING (public.has_role(auth.uid(), 'admin')). Keeping roles out of the JWT and out of the user-editable profile prevents privilege-escalation attacks.
Do policies apply to joins and views?
Policies apply to base tables, and Postgres pushes them into joined queries. Views inherit the policies of their underlying tables when the view is created without SECURITY DEFINER. If you build a view with SECURITY DEFINER, it runs as its owner and bypasses RLS, which is sometimes what you want for a curated report but is dangerous by default.
What breaks if I forget the GRANT statement?
The request fails at the Data API boundary with a permission-denied error, before RLS even runs. This confuses people because the policy looks correct. The Data API roles (anon, authenticated) have no default privileges on public tables, so every table you create needs an explicit GRANT for the roles your policies mention, otherwise every request returns an error.
Can I audit which policies exist on a table?
Yes. Query pg_policies (SELECT * FROM pg_policies WHERE tablename = 'notes') or open the Authentication → Policies view in the Supabase dashboard. Both show the policy name, roles it applies to, the USING expression, and the WITH CHECK expression. Reviewing that list before every deploy is a cheap way to catch policies added by someone else that widened access.
Does RLS work with Supabase Realtime?
Yes. Realtime evaluates policies against the JWT of the subscribing client, so subscribers only receive change events for rows their SELECT policy would return. This means a badly written policy will silently hide legitimate updates from the client. Test realtime paths the same way you test HTTP reads: subscribe as two different users and confirm each only receives their own rows.

Keep reading

See also