# Supabase Row Level Security: a practical guide

> How Supabase Row Level Security works, with tested policy patterns for select, insert, update and delete, team access, Storage, and the mistakes that leak data.

Source: https://supahost.app/guides/supabase-row-level-security · Updated 2026-11-11

Row Level Security (RLS) is a Postgres feature that decides, row by row, which data each user can read or change. In Supabase it's what makes it safe to query the database straight from the browser: you turn RLS on for every table the Data API exposes, then write policies, such as "users can only see rows where `user_id` matches their own ID". Without RLS, anyone with your public key can read and write those tables.

## Why RLS matters with the Data API

Supabase's Data API (PostgREST) turns your tables into REST endpoints. Your frontend calls them with the public key, and every request runs as one of two Postgres roles:

- `anon`: the visitor isn't signed in.
- `authenticated`: the visitor is signed in. Their JWT carries their user ID, which `auth.uid()` returns. Anonymous sign-ins also use this role, and you can tell them apart by the `is_anonymous` claim in the JWT.

The public key is meant to be visible, since it ships with your frontend code. So the database is the only thing standing between a visitor and your data. Supabase's [RLS docs](https://supabase.com/docs/guides/database/postgres/row-level-security) say it plainly: a table in an exposed schema without RLS can be read and written by any role that has a grant on it.

## Enabling RLS

```sql
alter table public.notes enable row level security;
```

Once RLS is on, the table is **deny by default**: no rows come back through the API until a policy allows them. That's what you want. Enable RLS first, then open up access one policy at a time.

A table created with plain SQL doesn't get RLS automatically, so add the `alter table` line to every migration that creates a table. If you want it switched on for every new table, Supabase's docs show how to do that with an event trigger.

## Policies for each operation

A policy is a SQL condition attached to a table. Two clauses do the work:

- `USING` filters **existing** rows: which rows you can see, update or delete.
- `WITH CHECK` validates **new** rows: what you're allowed to insert, or what a row may look like after an update.

| Operation | `USING` | `WITH CHECK` |
|---|---|---|
| `select` | Yes | No |
| `insert` | No | Yes |
| `update` | Yes (old row) | Yes (new row) |
| `delete` | Yes | No |

Here is a full set for a `notes` table with a `user_id uuid` column:

```sql
create policy "Users can read their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users can create their own notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Users can update their own notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Users can delete their own notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );
```

Postgres needs one policy per operation; you can't list several in one `for` clause. If several permissive policies apply to the same operation, they're combined with `OR`, so any one of them is enough to grant access ([PostgreSQL docs](https://www.postgresql.org/docs/current/sql-createpolicy.html)).

Rows that fail `USING` are silently skipped. An update on someone else's note just changes zero rows, with no error. Rows that fail `WITH CHECK` raise an error and the whole statement is rolled back.

## Why `(select auth.uid())` instead of `auth.uid()`

Both work, but they perform very differently on large tables. Written bare, `auth.uid()` can be called once per row. Wrapped in `select`, Postgres runs it once per statement and reuses the result. Supabase recommends the wrapped form for all its helper functions, including `auth.jwt()`.

Two more performance rules from the same docs:

- **Index the columns your policies filter on.** A policy on `user_id` needs an index on `user_id`:

  ```sql
  create index notes_user_id_idx on public.notes using btree (user_id);
  ```

- **Always add a `TO` clause.** Without it a policy applies to every role, including `anon`, and Postgres evaluates the expression for requests that could never pass.

## TO authenticated vs anon

`to authenticated` limits a policy to signed-in users. `to anon` covers visitors who aren't signed in. For content anyone can read, such as published blog posts, name both:

```sql
create policy "Anyone can read published posts"
on public.posts for select
to anon, authenticated
using ( published = true );
```

Remember that `auth.uid()` is null for `anon`. A comparison with null is never true, so `auth.uid() = user_id` quietly matches nothing for signed-out users. That's safe, but it can make "why is my query empty?" confusing during development.

## Ownership and team patterns

### Single owner

The policies above are the ownership pattern. Two details make it reliable: give `user_id` a default so clients can't forget it, and reference `auth.users`.

```sql
alter table public.notes
  alter column user_id set default auth.uid(),
  add constraint notes_user_id_fkey foreign key (user_id) references auth.users (id) on delete cascade;
```

### Team membership

For shared data, keep memberships in their own table and check it in the policy:

```sql
create table public.team_members (
  team_id uuid not null references public.teams (id) on delete cascade,
  user_id uuid not null references auth.users (id) on delete cascade,
  role text not null default 'member',
  primary key (team_id, user_id)
);

create index team_members_user_id_idx on public.team_members using btree (user_id);
```

The extra index matters. The primary key starts with `team_id`, so it doesn't help lookups by `user_id` alone.

The membership table needs RLS too, which creates a trap: if a policy on `team_members` queries `team_members`, Postgres reports infinite recursion. The usual fix is a `security definer` function in a schema the API doesn't expose. It runs as its owner, so it can read memberships without going through RLS again:

```sql
create schema if not exists private;

create function private.user_team_ids()
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
  select team_id from public.team_members where user_id = (select auth.uid());
$$;

create policy "Members can read team projects"
on public.projects for select
to authenticated
using ( team_id in (select private.user_team_ids()) );
```

`set search_path = ''` stops the function from resolving names through a schema someone else controls, so every name inside it must be schema-qualified.

## Storage policies basics

Supabase Storage keeps file metadata in `storage.objects`, and access is controlled with RLS policies on that table ([Storage access control](https://supabase.com/docs/guides/storage/security/access-control)). A common layout is one folder per user, named after their ID:

```sql
create policy "Users upload into their own folder"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

create policy "Users read their own files"
on storage.objects for select
to authenticated
using ( bucket_id = 'documents' and (select auth.jwt()->>'sub') = owner_id );
```

Uploading needs only an `insert` policy. Overwriting an existing file (upsert) also needs `select` and `update`.

## Testing policies

Test policies as the roles your app actually uses. The SQL editor connects as `postgres` by default, and on Supabase that role has `bypassrls`, so a query that works there proves nothing. Supabase's [pgTAP guide](https://supabase.com/docs/guides/database/postgres/testing-row-level-security) impersonates a user inside a transaction that's rolled back:

```sql
begin;
set local role authenticated;
set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000';

select * from public.notes;  -- should return only this user's notes

rollback;
```

Test the failure paths as well: as `anon`, as a second user, and an update that tries to set `user_id` to someone else. A check that only confirms policies exist in `pg_policies` will pass even when the policy logic is wrong.

## Common mistakes

**Being vague about `WITH CHECK` on update.** If an update policy has only `USING`, Postgres reuses it as the check, so users still can't hand a row to someone else. The real danger is a `WITH CHECK` that's looser than `USING`, like `with check (true)` copied from somewhere else, which lets a user rewrite a row they own into one they shouldn't. Write both clauses on every update policy so the intent is visible. Updates also need a matching `select` policy, or they won't work as expected.

**Forgetting `select` for `insert ... returning`.** `supabase.from('notes').insert(row).select()` returns the new row, which requires the row to pass a `select` policy too. Without one, the insert fails with an error.

**Using `user_metadata` for authorization.** `raw_user_meta_data` (`user_metadata` in the JWT) can be edited by the user through `supabase.auth.updateUser()`. Never base roles or permissions on it. Use `app_metadata`, which users can't change, or a table you control. Also, JWT claims don't update until the token refreshes.

**Assuming RLS restricts columns.** RLS works on rows. A user who can update their own `profiles` row can change every column in it, including `is_admin`. Limit updatable columns with column privileges:

```sql
revoke update on table public.profiles from authenticated;
grant update (display_name, avatar_url) on table public.profiles to authenticated;
```

**Views that bypass RLS.** By default a view runs with its owner's permissions, and views created in Supabase are usually owned by `postgres`, so they ignore your policies. On Postgres 15 and later, make the view respect the caller's RLS:

```sql
create view public.my_notes
with (security_invoker = true)
as select id, title from public.notes;
```

**`security definer` functions in `public`.** Functions in an exposed schema can be called through the API's `/rpc` endpoint. A `security definer` function there runs with its owner's rights for anyone who calls it. Keep them in a private schema, as in the team example.

**Using the secret key in the browser.** The secret and `service_role` keys bypass RLS completely. They belong on your server only.

## Same rules on any Supabase host

RLS is plain Postgres, so these policies behave the same wherever Supabase runs. On SupaHost you write them in the same Supabase Studio ([Studio docs](/docs#studio)), and the same SQL works unchanged. For moving an existing project, see [migrating a Supabase project](/guides/migrate-supabase-project).

## FAQ

### Is RLS enabled by default in Supabase?
Don't rely on it. A table created with SQL needs `alter table ... enable row level security`, and Supabase recommends enabling it on every table in an exposed schema. Check each table before you ship.

### What does `auth.uid()` return?
The signed-in user's ID from their JWT, as a `uuid`. For requests that aren't signed in, it returns null.

### What's the difference between USING and WITH CHECK?
`USING` decides which existing rows a statement can see or touch. `WITH CHECK` decides whether new or changed rows are allowed. Update policies use both.

### Why does my query return no rows after enabling RLS?
RLS denies everything until a policy allows it. Add a `select` policy for the right role, and check that the request is signed in if the policy uses `auth.uid()`.

### Does the service role key bypass RLS?
Yes. The secret and `service_role` keys skip RLS entirely, so only use them on servers you control, never in client code.

### Do views respect RLS in Supabase?
Not by default. Create views with `security_invoker = true` (Postgres 15 and later) so the caller's policies apply.
