Skip to content
HomeArticlesWorksContact

Nabeel Nashid © 2026

technology4 min read

Supabase Row Level Security: A Practical Guide to RLS Policies

Learn how Supabase Row Level Security works and how to write safe, performant RLS policies with real examples for public reads and authenticated writes.

N
Nabeel Nashid
Designer & Developer

Row Level Security (RLS) is the feature that makes Supabase safe to use directly from the browser. Instead of routing every request through your own backend to check permissions, you define the rules in the database itself — and Postgres enforces them for every query.

When RLS is set up correctly, a leaked anon key is not a disaster. When it is set up incorrectly, or not at all, your data is exposed. This guide covers the mental model, the policies you need most often, and the performance traps to avoid.

What Row Level Security actually does

Every Postgres table can have RLS enabled. Once it is, no row is visible or writable unless a policy explicitly allows it. Policies are written per operation — SELECT, INSERT, UPDATE, DELETE — and per role, such as anon (signed out) or authenticated (signed in).

The key idea: the database, not your application code, decides what a user can see.

Enabling RLS

alter table notes enable row level security;

That single line locks the table down completely. From that moment, nothing works until you add policies. This is why the Supabase dashboard warns loudly about tables without RLS.

The policy you will write most: public reads

For content that is meant to be public — blog posts, product listings, documentation — allow anonymous reads but keep writes locked:

create policy "Public read"
  on posts for select
  using (true);

The using clause is a filter applied to existing rows. true means "every row is readable".

Restricting writes to the owner

Most user data should only be editable by the user who owns it. Supabase exposes the signed-in user's id as auth.uid():

create policy "Insert own rows"
  on notes for insert
  with check (auth.uid() = user_id);

create policy "Update own rows"
  on notes for update
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

create policy "Delete own rows"
  on notes for delete
  using (auth.uid() = user_id);

Notice the difference: using controls which existing rows you can act on, while with check validates the new row you are trying to write. For UPDATE you usually want both.

A complete example

Imagine a table of notes where each note belongs to a user. The full setup looks like this:

create table notes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null default auth.uid() references auth.users(id),
  title text not null,
  body text,
  created_at timestamptz not null default now()
);

alter table notes enable row level security;

create policy "Public can read notes"
  on notes for select
  using (true);

create policy "Owner can write notes"
  on notes for all
  to authenticated
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

Setting user_id to auth.uid() as a default means you cannot accidentally insert a row on behalf of someone else.

Roles: anon vs authenticated

  • anon — requests made with the public anon key and no session. Treat everything here as untrusted.
  • authenticated — requests carrying a valid user JWT.
  • service_role — bypasses RLS entirely. Never expose it to the browser.

You can target a policy at a role with to authenticated. If you omit the role, the policy applies to everyone, including anonymous visitors.

Performance: wrap auth.uid() in a subquery

This is the most common RLS performance mistake. Postgres may re-evaluate auth.uid() for every row, which turns a fast query into a slow one on large tables. Wrapping it in a subquery lets the planner evaluate it once:

create policy "Owner can read"
  on notes for select
  using ((select auth.uid()) = user_id);

You should also add an index on the column used in the policy, such as user_id. RLS filters are just WHERE clauses, so they benefit from the same indexing as any other query.

Testing your policies

Do not assume a policy works — prove it. The simplest approach is to run queries with the anon and authenticated roles using set role, or use the Supabase SQL editor and the table editor's RLS preview. For automated tests, sign in as two different users and confirm each can only see their own rows.

Common pitfalls

  • RLS disabled by default on new tables. Always enable it immediately after creating a table.
  • Policies that are too broad. using (true) on UPDATE lets anyone edit anything.
  • Forgetting with check. A user could move a row into someone else's ownership.
  • Trusting the client. Never rely on the frontend to filter data. RLS is the boundary.

Final thoughts

Row Level Security is what lets you talk to the database directly from the browser without giving away control. Enable it on every table, write the smallest policy that works, wrap auth.uid() in a subquery, and index the columns your policies filter on. If you are building your first app with this stack, start with the Next.js + Supabase setup guide.

All articles

Related articles