# Supabase RLS policies: examples and common mistakes

Source: https://www.plutonapps.com/resources/supabase-rls-policy-examples

Published 2026-10-02. By Plutonapps Engineering.

A cookbook of row-level security policies for Supabase apps: five common patterns, how to test each one, and why policies fail.

A good Supabase RLS policy names one operation, one role and one rule. For example, "for select to authenticated using ((select auth.uid()) = user_id)" lets a signed-in user read only their own rows. As of 2 October 2026, that is the pattern Supabase's documentation shows. This guide builds five variations of it: owner, team, public read, admin and file storage. The usual mistakes are a missing policy or grant, a WITH CHECK that lets anything in, roles that users can edit, and a key that skips RLS.

This is a cookbook. Each pattern below gives the policy, what it allows, and a test that proves it works. If you need the concept first, read our entry on [row-level security](https://www.plutonapps.com/resources/what-is-row-level-security).

## How does a Supabase RLS policy work?

Your app's browser talks to the database directly through Supabase's API. Each request arrives as anon, for visitors who are not signed in, or authenticated, for signed-in users. The helper auth.uid() returns the signed-in user's id, or null when nobody is signed in.

A policy is a rule Postgres adds to every query on a table. It has up to two parts. USING decides which existing rows a user can see or touch. WITH CHECK decides which new or changed rows a user may write. Per the PostgreSQL documentation, a row that fails USING is silently left out, while a row that fails WITH CHECK makes the whole command fail with an error.

| Operation | Clause it needs | What a failure looks like |
| --- | --- | --- |
| select | USING | Fewer rows, or none, with no error |
| insert | WITH CHECK | An error (code 42501) |
| update | USING and WITH CHECK | No rows changed, or an error if the new row breaks the rule |
| delete | USING | No rows deleted, with no error |

Two more rules shape every example below. If RLS is on and no policy applies, Postgres assumes "default deny": nobody gets anything. And policies of the default kind, called permissive, are combined with OR, so one loose policy opens the table even if every other policy on it is strict.

Policies are only half of access. Grants decide whether a role may run an operation on a table at all. Supabase's documentation says that on existing projects, a new public table starts with every privilege granted to anon, authenticated and service_role, and policies do not take them back. Grant each role only what it needs. A role with no grant gets error 42501 before any policy runs.

## How do I let users see only their own rows?

This is the owner pattern, for profiles, notes, settings and anything else one person owns. The table needs a user_id column filled with the auth user's id. Write one policy per operation, each with "to authenticated":

| Policy | Rule |
| --- | --- |
| Read own rows | for select to authenticated using ((select auth.uid()) = user_id) |
| Create own rows | for insert to authenticated with check ((select auth.uid()) = user_id) |
| Change own rows | for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id) |
| Delete own rows | for delete to authenticated using ((select auth.uid()) = user_id) |

The update policy names both clauses. WITH CHECK decides what the changed row may look like, so a user cannot hand a row to another account by changing its user_id. Without it, Postgres reuses USING for the new row. An insert policy has only WITH CHECK, as there is no existing row to filter. Supabase also notes that an update needs a matching select policy.

The test: sign in as user A and create a row. Sign in as user B and try to read, update and delete it through the API. Then, as B, try to insert a row with A's user_id. None of the four may work: the read comes back empty, the update and delete change nothing, and the insert fails. Then check as A that the row is unchanged.

## How do I share rows with a team or workspace?

In a product with teams, rows belong to a workspace rather than a person. This is [multi-tenancy](https://www.plutonapps.com/resources/what-is-multi-tenancy), and the policy asks whether the current user is a member of the row's workspace. Keep membership in its own table, for example members (workspace_id, user_id, role), and check it like this:

- Read: for select to authenticated using (workspace_id in (select workspace_id from members where user_id = (select auth.uid()))).
- Write: the same condition in WITH CHECK on insert and update, so nobody can move a row into a workspace they do not belong to.

Notice the shape: the subquery collects the user's workspaces once, and each row is checked against that list. In Supabase's performance guide, rewriting a join-style policy into this shape took one test query from 9,000 ms to 20 ms. The members table needs RLS too, and its own policies must not let users add themselves to a workspace.

When the membership check grows complex, move it into a security definer function, such as is_member(workspace_id). Supabase explains that such a function runs as its owner, which on Supabase is postgres, a role that bypasses RLS. So set search_path to an empty string, name every table with its schema, and keep the function in a schema your API does not expose, as Supabase's examples do.

The test: create two workspaces. As a member of the first, try to read, change and delete rows in the second, and to insert a row carrying its id. On Zulu, a meetings workspace whose system we rebuilt, one automated test runs the read, change and delete checks across all 67 workspace-scoped tables. There, the boundary lives in the application layer, not in row policies. The [Zulu case study](https://www.plutonapps.com/work/zulu) explains why.

## How do I make some rows public?

Blog posts, product listings and public profiles must be readable by visitors who are not signed in. Grant read access to both roles, but only to public rows:

- Read: for select to anon, authenticated using (status = 'published').
- Write: owner or team policies as above, for authenticated only.

Name the roles on every policy. A policy without a TO clause applies to every role, including anon. In Supabase's performance guide, adding TO authenticated took a query by an anonymous user from 170 ms to under 0.1 ms, because the rest of the policy no longer runs for anon.

The test: log out and request the table. Published rows should come back, and drafts should not. Any insert, update or delete should either fail with an error or change nothing.

## How do I give admins access safely?

The admin pattern is where a small mistake does the most damage. The role must live where users cannot change it. Supabase warns that raw_user_meta_data can be updated by the authenticated user and "is not a good place to store authorization data". Use app metadata, which users cannot update, or a separate roles table with its own RLS.

- With app metadata: using ((select auth.jwt()) -> 'app_metadata' ->> 'role' = 'admin'). A change to the role takes effect only when the user's token is refreshed.
- With a roles table: using ((select is_admin())), where is_admin() is a security definer function that reads the table.

Add the admin rule as its own permissive policy next to the owner policy. Because permissive policies combine with OR, admins see everything and everyone else sees their own rows. For a rule that must hold for everybody, such as "never show rows marked deleted", use a restrictive policy, which is combined with AND.

The test: as an ordinary user, try to set your own role through the API, both in user metadata and in the roles table. Then check that the admin pages refuse you even if you open their address directly.

## How do I write RLS policies for Supabase Storage?

Files follow the same rules. Supabase Storage keeps file records in the storage.objects table and allows no uploads until you add policies. A common pattern gives each user a folder named after their id:

- Upload: for insert to authenticated with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')).
- Read: for select to authenticated using (bucket_id = 'avatars' and owner_id = (select auth.jwt() ->> 'sub')).

An upload needs only insert permission, but an upsert, which replaces an existing file, needs insert, select and update. Always filter on bucket_id, or one policy will apply to every bucket.

The test: as user B, try to upload into user A's folder, list it, and download one of A's files by its path.

## Why is my Supabase RLS policy not working?

When a query returns nothing or an insert fails with "new row violates row-level security policy", the cause is usually one of these:

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Select returns an empty list, no error | No select policy, or USING is false for this user | Add or correct the select policy for the right role |
| Insert fails with error 42501 | WITH CHECK fails, often because user_id is not the signed-in user; or the role has no grant on the table | Set user_id to auth.uid(), with a column default or in the request; check the table's grants |
| Update changes nothing | No select policy, or USING excludes the row | Add a select policy; check USING |
| Works in the SQL editor, not in the app | Dashboard queries run as postgres, which bypasses RLS | Test as a real user through the API |
| Anonymous users get nothing | auth.uid() is null for them, so owner checks are false | Add a separate policy for anon that covers the public rows |
| Data visible through a view | Views run as their creator and skip RLS by default | Create the view with security_invoker = true (Postgres 15+) |

Our [Lovable security checklist](https://www.plutonapps.com/guides/lovable-security-checklist) covers Supabase's own advisor warnings and the two kinds of RLS errors. This list is about policies you write yourself.

## What are the most common Supabase RLS mistakes?

- Leaving RLS off. On existing projects, new public tables start with full privileges for anon and authenticated, so anyone with your publishable key can read and change them.
- Writing "using (true)" to make an error go away. It turns a closed table into an open one for every role the policy names.
- Writing a WITH CHECK that is looser than USING, such as "with check (true)", so users can write rows they could never read.
- Storing roles in user metadata, which users can edit.
- Shipping a secret key, or the older service_role key, to the browser. Both bypass every policy. Our entry on the [Supabase anon key vs service role key](https://www.plutonapps.com/resources/supabase-anon-key-vs-service-role-key) explains which key goes where.
- Using views or security definer functions without checking whose rights they run with.
- Testing only in the SQL editor, which runs as postgres and skips RLS, instead of as two real users.

## How do I make RLS policies fast?

Policies run on every row a query touches, so a slow policy makes the whole app slow. Here are three changes from Supabase's performance guide, measured on a 100,000-row test table:

1. **Wrap auth functions in select** Write (select auth.uid()) instead of auth.uid(). Postgres then runs it once per query instead of once per row. Supabase measured one test falling from 179 ms to 9 ms. Do this only when the result does not depend on the row.
2. **Index the columns policies use** Add an index on user_id, workspace_id and any other column a policy compares. Supabase reports improvements of over 100 times on large tables, and 171 ms to under 0.1 ms in its test.
3. **Filter in the query too** RLS is for security, not filtering. Add the same condition to your app's query, such as eq('user_id', id), so Postgres can use the index. Supabase measured 171 ms falling to 9 ms.

## How do I test RLS policies before every release?

Clicking through the app with two accounts is a good start, but it does not repeat itself. Supabase supports pgTAP, a testing tool that runs inside the database. Tests are SQL files in the supabase/tests folder, run with "supabase test db". Its documentation lists what to assert: a missing grant or a failed WITH CHECK raises error 42501, and a USING filter matches zero rows with no error. So a write that did not error proves nothing.

Write one test per pattern above, as user A, user B, an admin and an anonymous visitor, and run them on every change. A new prompt or migration can quietly widen a policy, and a test is what catches it. On Looph, row-level security is on for all 172 public tables, with 1,180 policies, and 110 pgTAP suites test those rules inside a real database. The [Looph case study](https://www.plutonapps.com/work/looph) shows how one database function answers every permission question.

> **Policies tested on every change** You keep prompting in Lovable, and our engineers turn each version into the live product. On a Plutonapps plan, they write your access rules, test them in a real database and re-run the full test suite on every change before it reaches your users.

Starter is $2,999 a month paid annually, or $3,999 month to month, and Growth is custom; see [pricing](https://www.plutonapps.com/pricing). Or take the free [production-readiness check](https://www.plutonapps.com/tools/production-readiness-check): 19 questions, a score out of 100 and the three risks to fix first.

## Sources

Each fact above comes from one of these pages, checked on 2 October 2026.

- Supabase documentation: Row Level Security (supabase.com/docs/guides/database/postgres/row-level-security).
- Supabase documentation: RLS Performance and Best Practices (supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv).
- Supabase documentation: Postgres Roles (supabase.com/docs/guides/database/postgres/roles).
- Supabase documentation: Storage Access Control (supabase.com/docs/guides/storage/security/access-control).
- Supabase documentation: Testing Your Database (supabase.com/docs/guides/database/testing).
- PostgreSQL 18 documentation: CREATE POLICY (postgresql.org/docs/current/sql-createpolicy.html).
- Case-study facts: our Looph and Zulu case studies, as published on this site.

## Frequently asked questions

### What is the difference between USING and WITH CHECK in Supabase?

USING decides which existing rows a user can read, update or delete, and rows that fail it are silently left out. WITH CHECK decides which new or changed rows a user may write, and a row that fails it makes the command fail with an error. An update uses both, and without WITH CHECK, Postgres reuses USING.

### Why does my Supabase query return an empty array with RLS on?

Usually because no select policy lets this user see those rows. With RLS on and no matching policy, Postgres returns nothing rather than an error. Check that a select policy exists for the role making the request.

### Why should I write (select auth.uid()) instead of auth.uid()?

Wrapping the function in select lets Postgres run it once per query instead of once per row. In Supabase's performance guide, one test fell from 179 ms to 9 ms, and Supabase's own example policies use this form.

### Where should I store user roles for RLS in Supabase?

In app metadata or in a separate roles table protected by its own policies. Supabase warns that user metadata can be changed by the signed-in user, so with a role kept there, anyone could make themselves an admin.

### Does the Supabase service role key bypass RLS?

Yes. Secret keys, and the older service_role key, act as the service_role, which has the bypass RLS attribute and ignores every policy. Keep them on the server. The browser should use the publishable key, which reaches only what your grants and policies allow.
