github

Supabase Row Level Security (RLS) Explained

Guides · Updated 3 October 2026

Row level security (RLS) is the Postgres feature that decides which rows each user can read or change. On Supabase it is the main thing standing between your data and anyone holding your public API key. This guide explains how it works, the mistakes that defeat it, and what a correct policy looks like.

What row level security is

RLS is a setting on a table. When it is on, every query against that table is filtered through policies. A policy is a SQL condition attached to a table, an operation (SELECT, INSERT, UPDATE or DELETE) and a set of roles. A row can be read or written only if a policy for that operation and role allows it.

With RLS on and no policies, nothing is allowed. With RLS off, policies are ignored and the ordinary table privileges decide everything.

Why the anon key makes RLS mandatory

Supabase generates a REST API for every table in the public schema. Your frontend calls it with the anon key, which newer projects call the publishable key. That key is in your JavaScript, so anyone can copy it from the browser and send their own requests.

Each request runs as a Postgres role. Without a signed-in user it runs as anon. With a user session it runs as authenticated. If a table has RLS off and those roles have privileges on it, which is the default on existing projects, anyone can read, insert, update and delete its rows.

Tables created in the dashboard Table Editor get RLS by default. Tables created with SQL, migrations or an ORM do not, so you have to enable it yourself.

USING vs WITH CHECK

A policy can have two conditions, and they do different jobs.

  • USING is checked against rows that already exist. It decides which rows a SELECT returns and which rows an UPDATE or DELETE can touch.
  • WITH CHECK is checked against the new version of a row. It decides which rows an INSERT can add and what a row may look like after an UPDATE.

SELECT and DELETE policies take only USING. INSERT policies take only WITH CHECK. UPDATE policies take both. If you leave WITH CHECK off an UPDATE policy, Postgres reuses the USING condition for the new row. Write both anyway, so the intent is clear to the next reader.

A correct owner-only policy

The most common rule is that users see and change only their own rows. This example assumes the table has a user_id column that stores the id of the signed-in user.

ALTER TABLE public.<table_name> ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users read own rows"
  ON public.<table_name> FOR SELECT
  TO authenticated
  USING ((SELECT auth.uid()) = user_id);

CREATE POLICY "Users insert own rows"
  ON public.<table_name> FOR INSERT
  TO authenticated
  WITH CHECK ((SELECT auth.uid()) = user_id);

CREATE POLICY "Users update own rows"
  ON public.<table_name> FOR UPDATE
  TO authenticated
  USING ((SELECT auth.uid()) = user_id)
  WITH CHECK ((SELECT auth.uid()) = user_id);

CREATE POLICY "Users delete own rows"
  ON public.<table_name> FOR DELETE
  TO authenticated
  USING ((SELECT auth.uid()) = user_id);

Four details matter here. There is one policy per operation instead of a single FOR ALL policy. Each policy is scoped with TO authenticated, so it never applies to visitors who are not signed in. The UPDATE policy has both conditions, so a user cannot hand a row to someone else. And auth.uid() is wrapped in a SELECT, which lets Postgres evaluate it once per statement instead of once per row.

Add an index on user_id so the check stays fast on large tables.

Mistake: policies that allow everything

USING (true) matches every row. On a SELECT policy for public content, such as published posts, that can be exactly what you want. On INSERT, UPDATE or DELETE, or on a table with private data, it means the whole role can read or write the table. It often gets added to make an error go away during development and is never removed.

A policy with no TO clause applies to every role, including anon. Combine that with a loose condition and visitors who are not signed in get the same access as your users.

Multiple permissive policies are a quieter version of the same problem. Policies for the same table, operation and role are combined with OR, so a row is allowed if any one of them passes. One broad policy cancels every strict one beside it. For a condition that must always hold, create the policy AS RESTRICTIVE. Restrictive policies are combined with AND.

Mistake: RLS and policies out of step

RLS enabled with zero policies denies everything to the API. Your data is safe, but the app gets empty results and no error, which is usually a bug and not a decision. The tempting fix is USING (true). Write the real policy instead.

The opposite case is dangerous. You can create policies on a table without enabling RLS. The policies show up in the dashboard and look like protection, but they are not enforced until you enable RLS on the table.

Mistake: trusting user_metadata

It is common to store a role on the user and check it in a policy, for example allowing access when user_metadata says the user is an admin. Users can update their own user_metadata from the client, so anyone can make themselves an admin. Use app_metadata for anything a policy depends on. Users cannot change it.

A related gap: RLS works on rows, not columns. If users can update their own row in a table that also holds a role, plan or balance column, they can change those columns too. Revoke UPDATE on the table and grant it only on the columns users may edit, or guard the sensitive columns with a trigger.

Test policies the way users reach them

The SQL Editor in the dashboard runs as the postgres role, which bypasses RLS. A query that returns the right rows there tells you nothing about what your users can see. Test through the API with the anon key and with a real user session, or use role impersonation in the dashboard. Locksoup runs these policy checks automatically with a read-only role.

Related checks

Check your own project

Locksoup runs these checks on your Supabase database in about a minute, with a read-only role, and gives you the SQL to fix what it finds.

Check my project