Supabase Row Level Security (RLS): The Complete Guide for 2026
Featured snippet answer: Supabase Row Level Security (RLS) is a PostgreSQL feature that controls which rows a user can read or modify by attaching SQL policies to a table. In Supabase, every request runs as the anon or authenticated Postgres role, and RLS policies use auth.uid() and JWT claims to decide row access — so your authorization rules live in the database, not in client code that anyone can bypass.
Most data leaks in Supabase apps are not exotic. They come from a table that shipped with Row Level Security turned off, or a policy that quietly evaluates to true. The Supabase client library runs in your users' browsers, and the anon key is public by design. The database is your real security boundary — and RLS is how you draw it.
This guide is the practical version I wish I'd had when I shipped my first multi-tenant app on Supabase. It covers how RLS actually works, every policy type with production-ready SQL, a full SaaS schema with organizations and teams, the mistakes that cause real incidents, and how to keep policies fast on large tables.
What is Row Level Security?
Row Level Security is a PostgreSQL feature that filters rows based on the user running the query. Without it, select * from tasks returns every row a role can access. With RLS enabled and a policy in place, the same query silently returns only the rows that satisfy the policy — as if the rest don't exist.
A policy is a named rule attached to a table for a specific command (SELECT, INSERT, UPDATE, DELETE, or ALL). It contains a boolean SQL expression. If the expression returns true for a row, the operation is allowed for that row; otherwise it's blocked. There is no error and no partial result — non-matching rows are simply invisible.
Two clauses do the work:
| Clause | Applies to | Question it answers |
|---|---|---|
USING | SELECT, UPDATE, DELETE | "Can the user *see* this existing row?" |
WITH CHECK | INSERT, UPDATE | "Is the user allowed to *write* this new/changed row?" |
UPDATE is the one that uses both: USING decides which rows are eligible to update, and WITH CHECK validates the resulting row.
Why Supabase uses PostgreSQL RLS
Supabase exposes your database over HTTP through PostgREST. That's what makes the auto-generated REST and realtime APIs possible — but it also means your tables are reachable from the public internet with a key that ships in your frontend bundle.
That architecture only stays safe because of one detail: incoming requests don't run as a superuser. PostgREST authenticates the JWT, then switches to a limited Postgres role for the duration of the query:
anon— unauthenticated visitors (the public anon key, no valid user JWT).authenticated— a logged-in user with a valid Supabase Auth JWT.service_role— backend/admin access that bypasses RLS entirely.
Because anon and authenticated are not the table owner, RLS is enforced against them. Your policies are the only thing standing between a curious user opening DevTools and your entire users table. This is why Supabase shows a loud warning whenever a table in an exposed schema has RLS disabled.
How RLS Works Internally
When a query hits a table with RLS enabled, Postgres rewrites it. Your policy expression is injected into the query as an additional WHERE condition (and as a check constraint for writes). So this request from the client:
select * from tasks;effectively becomes, for an authenticated user:
select * from tasks
where ( /* your SELECT policy expression */ );Two consequences fall out of this, and both matter later:
- 1.Policies run per row, inside the query planner. A slow or non-sargable policy expression becomes a slow query. RLS columns need indexes just like any other
WHEREclause. - 2.Policies for the same command are combined with `OR`. If you create three permissive
SELECTpolicies, a row is visible when *any* of them returnstrue. It's easy to widen access by accident by adding "just one more" policy.
Where does the user's identity come from? Supabase puts the verified JWT claims into a session setting, and helper functions read them:
-- auth.uid() is essentially this:
select nullif(
current_setting('request.jwt.claims', true)::jsonb ->> 'sub',
''
)::uuid;For an anonymous request there's no sub, so auth.uid() returns NULL — which is exactly why comparisons like auth.uid() = user_id naturally fail closed for anon users.
Authentication vs Authorization
These get conflated constantly, and RLS only solves one of them.
- Authentication ("who are you?") is handled by Supabase Auth. It verifies credentials and issues a signed JWT. RLS does not authenticate anyone.
- Authorization ("what are you allowed to touch?") is what RLS enforces, using the identity from that JWT.
The takeaway: RLS trusts that the JWT is valid (Supabase verifies the signature before your policies run), and then your job is to write policies that map that identity to the correct rows. If your authorization logic lives in JavaScript instead of policies, it isn't authorization — it's a suggestion.
Enabling RLS
RLS is opt-in per table. Until you enable it, a permissive Supabase project will happily serve the whole table to anon.
-- Turn on RLS. Until a policy exists, this DENIES all access
-- to anon/authenticated (fail-closed) — which is what you want.
alter table public.profiles enable row level security;- What it does: Activates policy enforcement for the table. With RLS on and *no* policies, non-owner roles get zero rows.
- Why it's secure: It fails closed. A table is locked down the moment you enable RLS, before you've written a single policy.
- Common mistake: Creating policies but forgetting the
enable row level securitystep — the policies exist but are never enforced. - When to use it: On every table in the
publicschema. No exceptions for "internal" tables; if it's in an exposed schema, it's reachable.
Tip: If you query a table *as its owner* (for example in a SECURITY DEFINER function), RLS is bypassed unless you also run alter table public.profiles force row level security;. Through the Supabase API you're never the owner, so this mainly matters for server-side functions.
Creating Your First Policy
A minimal, secure starting point — users can read only their own profile row:
create policy "Users can read own profile"
on public.profiles
for select
to authenticated
using ( (select auth.uid()) = id );Read it like a sentence: *for authenticated users, on SELECT, allow rows where the current user's id equals the row's id.*
Two habits worth adopting from day one:
- Always specify `to <role>`. Without it the policy applies to
public(all roles). Namingauthenticatedmeans Postgres skips this policy entirely foranon, which is both safer and faster. - Wrap auth calls in a subquery: `(select auth.uid())`. This lets the planner evaluate it once per query instead of once per row. On a 10-row table it's irrelevant; on a million-row table it's the difference between an index scan and a meltdown.
SELECT Policies
SELECT controls visibility. This is the policy attackers probe first, because a too-broad read policy leaks data even if writes are locked down.
-- Read your own rows only.
create policy "Read own tasks"
on public.tasks
for select
to authenticated
using ( (select auth.uid()) = user_id );- What it does: Returns only rows the current user owns.
- Why it's secure:
auth.uid()isNULLfor anon, so the comparison is never true for unauthenticated requests. - Common mistake:
using (true)"just to get things working." That exposes the whole table; it rarely gets tightened later. - When to use it: The default for any per-user resource.
INSERT Policies
INSERT uses WITH CHECK only — there's no existing row to inspect, just the incoming one.
create policy "Insert own tasks"
on public.tasks
for insert
to authenticated
with check ( (select auth.uid()) = user_id );- What it does: Allows a user to create rows only where
user_idis their own id. - Why it's secure: It stops a user from inserting rows attributed to someone else (a classic privilege-escalation trick:
insert ... user_id = '<victim>'). - Common mistake: Forgetting the INSERT policy entirely. With RLS on and no INSERT policy, every insert is rejected — a confusing "new row violates row-level security policy" error.
- When to use it: Any table where users create their own records.
Tip: Don't rely on the client to send the right user_id. Set it server-side or default it: alter table public.tasks alter column user_id set default auth.uid(); — then the WITH CHECK becomes a guarantee rather than a hope.
UPDATE Policies
UPDATE is the subtle one. USING filters which rows can be targeted; WITH CHECK validates the row *after* changes. Omit WITH CHECK and a user could update a row they own into a row they shouldn't (e.g., reassigning ownership).
create policy "Update own tasks"
on public.tasks
for update
to authenticated
using ( (select auth.uid()) = user_id ) -- which rows you may edit
with check ( (select auth.uid()) = user_id ); -- what the row may become- What it does: Lets users edit their own rows and prevents them from re-pointing a row to another user.
- Why it's secure: Both the before-state and after-state are constrained to the current user.
- Common mistake: Providing only
USING. The update is allowed and theWITH CHECKdefaults to permissive for the new values, so a user can changeuser_idto anyone. - When to use it: Every user-editable table.
DELETE Policies
DELETE uses USING only.
create policy "Delete own tasks"
on public.tasks
for delete
to authenticated
using ( (select auth.uid()) = user_id );- What it does: Allows users to delete only their own rows.
- Why it's secure: Non-owners match zero rows, so a malicious
delete from tasksremoves nothing it shouldn't. - Common mistake: Skipping it and "soft-deleting" from the client without a policy — the hard
DELETEendpoint is still exposed. - When to use it: Anywhere destructive actions are user-scoped. For audit-sensitive data, consider denying
DELETEoutright and using adeleted_atcolumn with anUPDATEpolicy instead.
Understanding auth.uid()
auth.uid() returns the UUID of the currently authenticated user (the JWT sub claim), or NULL when there's no valid user. It is the workhorse of almost every policy.
Things to keep straight:
- It returns a
uuid. Your ownership columns (user_id,id) should also beuuidreferencingauth.users(id). Type mismatches cause silent failures or casts. - It is
NULLforanon. That's a feature: ownership checks fail closed. - Prefer
(select auth.uid())in policies for the planner caching benefit described earlier.
-- A profiles table keyed to the auth user is the canonical pattern.
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text,
created_at timestamptz default now()
);Using JWT Claims
auth.uid() covers identity. For roles and tenant info you reach for auth.jwt(), which returns the full claims object.
-- Read a custom claim, e.g. an app-level role.
select (select auth.jwt() ->> 'user_role'); -- top-level claim
select (select auth.jwt() -> 'app_metadata' ->> 'plan'); -- nested in app_metadataA few realities about claims:
- `app_metadata` is trustworthy; `user_metadata` is not. Users can edit their own
user_metadatathrough the Auth API. Never base authorization on it. Put roles and plan tiers inapp_metadata, which only your backend/service role can change. - Custom top-level claims require a hook. To get a clean
user_roleclaim, configure a Custom Access Token Hook. Until the user's token refreshes, claim changes won't appear. - Claims are a snapshot. A role revoked in the database still lives in an unexpired JWT. For instant revocation, check a table instead of (or in addition to) the claim.
-- Admin-only access using a custom claim.
create policy "Admins manage all tasks"
on public.tasks
for all
to authenticated
using ( (select auth.jwt() ->> 'user_role') = 'admin' )
with check ( (select auth.jwt() ->> 'user_role') = 'admin' );- What it does: Grants full access to users whose token carries
user_role = admin. - Why it's secure: The claim is signed by Supabase Auth and only settable server-side via the hook +
app_metadata. - Common mistake: Reading the role from
user_metadata, which users control. - When to use it: Coarse-grained roles. For per-resource permissions, prefer table-driven checks (below).
Team-based Access Control
Once more than one person can touch a row, ownership (auth.uid() = user_id) isn't enough. You need membership. The clean pattern is a join table plus a SECURITY DEFINER helper function.
Why a function? If a policy on organization_members queries organization_members, you get infinite recursion. A SECURITY DEFINER function runs with the definer's privileges, bypasses RLS *inside the function*, and breaks the cycle. Lock it down with an empty search_path.
create table public.organization_members (
organization_id uuid references public.organizations(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text not null default 'member', -- 'owner' | 'admin' | 'member'
primary key (organization_id, user_id)
);
-- Reusable, recursion-safe membership check.
create or replace function public.is_org_member(org uuid)
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
select exists (
select 1
from public.organization_members m
where m.organization_id = org
and m.user_id = (select auth.uid())
);
$$;Now policies stay readable:
alter table public.organizations enable row level security;
create policy "Members can read their organizations"
on public.organizations
for select
to authenticated
using ( public.is_org_member(id) );- What it does: A user sees an organization only if they have a membership row.
- Why it's secure:
SECURITY DEFINER+set search_path = ''prevents both recursion and search-path hijacking. The function isstable, so the planner can cache it. - Common mistake: Writing the membership subquery directly in the policy and triggering recursion, or forgetting
set search_path. - When to use it: Any shared-ownership model — teams, orgs, workspaces.
Multi-tenant SaaS Example
Here's a complete, realistic schema: users belong to organizations, organizations have projects, projects have tasks. The rule we want: an authenticated user can only access data inside organizations they belong to, and admins/owners can manage members.
Schema
create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
created_by uuid references auth.users(id),
created_at timestamptz default now()
);
create table public.organization_members (
organization_id uuid references public.organizations(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text not null default 'member',
primary key (organization_id, user_id)
);
create table public.projects (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references public.organizations(id) on delete cascade,
name text not null,
created_at timestamptz default now()
);
create table public.tasks (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
is_done boolean default false,
assigned_to uuid references auth.users(id),
created_at timestamptz default now()
);Helper functions
-- Membership (defined above) + a role check + a project->org bridge.
create or replace function public.has_org_role(org uuid, roles text[])
returns boolean
language sql security definer set search_path = '' stable
as $$
select exists (
select 1 from public.organization_members m
where m.organization_id = org
and m.user_id = (select auth.uid())
and m.role = any(roles)
);
$$;
create or replace function public.can_access_project(p_project uuid)
returns boolean
language sql security definer set search_path = '' stable
as $$
select exists (
select 1
from public.projects pr
join public.organization_members m
on m.organization_id = pr.organization_id
where pr.id = p_project
and m.user_id = (select auth.uid())
);
$$;Policies — organizations
alter table public.organizations enable row level security;
create policy "read own orgs"
on public.organizations for select to authenticated
using ( public.is_org_member(id) );
create policy "create orgs"
on public.organizations for insert to authenticated
with check ( created_by = (select auth.uid()) );
create policy "owners update orgs"
on public.organizations for update to authenticated
using ( public.has_org_role(id, array['owner','admin']) )
with check ( public.has_org_role(id, array['owner','admin']) );Policies — organization_members
alter table public.organization_members enable row level security;
-- A user can see the member list of orgs they belong to.
create policy "read members of my orgs"
on public.organization_members for select to authenticated
using ( public.is_org_member(organization_id) );
-- Only owners/admins can add or remove members.
create policy "admins manage members"
on public.organization_members for all to authenticated
using ( public.has_org_role(organization_id, array['owner','admin']) )
with check ( public.has_org_role(organization_id, array['owner','admin']) );Policies — projects
alter table public.projects enable row level security;
create policy "members read projects"
on public.projects for select to authenticated
using ( public.is_org_member(organization_id) );
create policy "members write projects"
on public.projects for insert to authenticated
with check ( public.is_org_member(organization_id) );
create policy "members update projects"
on public.projects for update to authenticated
using ( public.is_org_member(organization_id) )
with check ( public.is_org_member(organization_id) );
create policy "admins delete projects"
on public.projects for delete to authenticated
using ( public.has_org_role(organization_id, array['owner','admin']) );Policies — tasks
alter table public.tasks enable row level security;
create policy "members access tasks"
on public.tasks for all to authenticated
using ( public.can_access_project(project_id) )
with check ( public.can_access_project(project_id) );The result: a logged-in user can list projects and tasks only for organizations where they have a membership row, owners/admins manage members and delete projects, and nobody can read across tenant boundaries — all enforced in the database, regardless of what the client sends.
Anonymous Access, Public Read, Private Write
Not everything is private. Marketing pages, public profiles, and published posts often need anonymous read access. Be explicit about the anon role.
-- Public read: anyone (logged in or not) can read published rows.
create policy "public read published"
on public.posts for select
to anon, authenticated
using ( published = true );
-- Private write: only the authenticated author can create/edit.
create policy "author writes"
on public.posts for insert
to authenticated
with check ( (select auth.uid()) = author_id );- What it does: Visitors see published posts; only authors create them.
- Why it's secure: The read policy filters on
published = true, so drafts stay hidden even from anon. Writes are gated to the authenticated owner. - Common mistake:
using (true)on the read policy, which exposes unpublished drafts. - When to use it: Blogs, public directories, shareable resources.
Service Role bypass
The service_role key has BYPASSRLS. Queries made with it ignore every policy. That's intentional — backends, cron jobs, and admin tooling need unrestricted access — but it makes the key catastrophic if leaked.
-- There is NO policy to write for service_role. It already bypasses RLS.
-- Your only job is to keep the key off the client.- What it does: Full read/write to every table, RLS notwithstanding.
- Why it's "secure": Only when the key lives exclusively in server environments (edge functions, your backend), never in frontend code, mobile apps, or public env vars.
- Common mistake: Putting
SUPABASE_SERVICE_ROLE_KEYin aNEXT_PUBLIC_*variable or shipping it in a mobile bundle. That hands every visitor a master key. - When to use it: Trusted server-side operations only — webhooks, migrations, admin dashboards behind your own auth.
Warning: Treat the service role key like a database password. If it ever touches client-side code or a public repo, rotate it immediately.
Common RLS Mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Forgetting to enable RLS | Table fully readable/writable by anon | alter table ... enable row level security; on every public table |
Policy returns true | Everyone sees everything | Scope with auth.uid() / membership checks |
auth.uid() vs wrong column | Policy never matches or matches too much | Compare to the actual ownership column, matching types (uuid) |
| Trusting client validation | Bypassed via direct API calls | Enforce in policies; the client is untrusted |
Exposing service_role key | Total compromise | Keep it server-side; rotate if leaked |
| Missing INSERT policy | All inserts rejected | Add a with check INSERT policy |
| Overly broad SELECT | Cross-tenant data leak | Narrow using to ownership/membership |
Not testing anon | Public exposure shipped to prod | Run every test as anon too |
| Recursive policy | "infinite recursion detected" | Move the check into a SECURITY DEFINER function |
UPDATE without WITH CHECK | Users reassign ownership | Add with check to constrain the new row |
Security Best Practices
- Enable RLS on every table in exposed schemas, then add policies. Default deny, then allow.
- Always specify the role (
to authenticated,to anon) so policies fail closed and skip irrelevant roles. - Put roles/tenancy in `app_metadata` or tables, never
user_metadata. - Default ownership columns server-side:
set default auth.uid()instead of trusting the client. - Prefer `SECURITY DEFINER` helper functions for membership/role logic — reusable, recursion-safe, indexable.
- Pin `search_path = ''` in every
SECURITY DEFINERfunction to prevent hijacking. - Deny by omission: if a command has no policy, it's blocked. Only open what you need.
- Re-check critical permissions against a table, not just the JWT, when instant revocation matters.
Debugging Policies
When a query returns nothing (or "violates row-level security policy"), inspect what the database sees.
-- What does Postgres think the current identity is?
select auth.uid(), auth.role(), auth.jwt();
-- List policies on a table.
select policyname, cmd, qual, with_check
from pg_policies
where tablename = 'tasks';
-- Reproduce a user's view in SQL by impersonating their JWT (see Testing).Common diagnoses:
auth.uid()isNULL→ you're running asanon; the JWT wasn't passed.- Insert rejected → missing or failing
WITH CHECK. - Empty
SELECT→ theUSINGexpression is false for those rows (wrong column, wrong type, or no membership row).
Performance Considerations
RLS is "free" until your tables get big. Then policy expressions dominate query cost because they run for every candidate row.
- Wrap auth calls:
(select auth.uid())and(select auth.jwt())are evaluated once per statement (as an InitPlan) instead of per row. This single change can turn a seconds-long query into a millisecond one on large tables. - Index the columns policies filter on. If your policy uses
user_idororganization_id, those columns need indexes:
create index on public.tasks (assigned_to);
create index on public.organization_members (user_id, organization_id);
create index on public.projects (organization_id);- Keep helper functions `stable` so the planner can optimize and cache them.
- Scope policies by role.
to authenticatedmeans the policy isn't even evaluated foranonrequests. - Watch the query plan.
explain analyzeyour hot queries as a normal user (after setting the JWT claims) and confirm policies use indexes rather than sequential scans. - For very large tables, prefer membership checks that hit a small, well-indexed join table over correlated subqueries across big tables.
Testing RLS
You can — and should — test policies in pure SQL by impersonating a user, without spinning up a client.
-- Run as an authenticated user with a specific uid.
begin;
select set_config(
'request.jwt.claims',
'{"sub":"00000000-0000-0000-0000-000000000001","role":"authenticated"}',
true
);
set local role authenticated;
-- These now run exactly as that user would see them:
select * from public.tasks; -- should only show their org's tasks
insert into public.tasks (project_id, title) values ('<other-org-project>', 'x'); -- should fail
rollback;Also test the unauthenticated path:
begin;
set local role anon;
select * from public.tasks; -- expect zero rows
rollback;For a regression suite, pgTAP lets you assert row counts and expected failures per role and run them in CI on every migration. The point is to test *negative* cases — that the wrong user sees nothing — not just that the happy path works.
Real-world Example Project
Putting it together, a typical task-manager SaaS migration looks like this, in order:
- 1.Create
organizations,organization_members,projects,tasks. - 2.
enable row level securityon all four. - 3.Create
is_org_member,has_org_role,can_access_project(SECURITY DEFINER,search_path = ''). - 4.Add the per-table policies from the Multi-tenant SaaS Example.
- 5.Add indexes on
organization_members(user_id, organization_id),projects(organization_id),tasks(project_id). - 6.Write pgTAP tests: owner can manage members, member can read but not delete projects, outsider sees nothing, anon sees nothing.
- 7.Run the test suite in CI before every deploy.
This is the same shape used by production multi-tenant apps: a thin membership table, a couple of SECURITY DEFINER helpers, and policies that read like business rules.
How RLS fits into your overall security posture
RLS is the authorization layer for your data, and it's the most important one to get right on Supabase. But it's a single layer. Even airtight policies don't cover misconfigured CORS, missing security headers, a stale TLS configuration, an exposed admin endpoint, or a leaked key in your frontend bundle.
Even well-written RLS policies are only one layer of application security. Regularly scanning your application for missing security headers, exposed endpoints, weak TLS configurations, and other common security issues with CodeSec helps identify problems before they reach production. It pairs well with the database-level controls here: RLS guards the data, and a scan catches the perimeter issues that sit in front of it. If you're hardening a Supabase project specifically, our Supabase security best practices guide is a good companion read.
Security Checklist (before production)
- ○RLS is enabled on every table in the
publicschema. - ○Every table has explicit policies for the commands it needs (
SELECT/INSERT/UPDATE/DELETE). - ○No policy uses
using (true)for writes or sensitive reads. - ○Every policy names a role (
to authenticated/to anon). - ○
INSERTpolicies set/validate ownership; ownership columns default toauth.uid(). - ○
UPDATEpolicies includeWITH CHECK. - ○Roles/tenancy come from
app_metadataor tables, neveruser_metadata. - ○
SECURITY DEFINERfunctions setsearch_path = ''. - ○Policy-filtered columns (
user_id,organization_id, foreign keys) are indexed. - ○Auth calls are wrapped as
(select auth.uid()). - ○
service_rolekey exists only in server environments; not in anyNEXT_PUBLIC_*var or client bundle. - ○Tests pass for the owner, a regular member, an outsider, and
anon. - ○An external scan (e.g., CodeSec) shows clean headers/TLS/exposed-endpoint results.
FAQ
Is RLS enabled by default in Supabase?
No. You must run alter table ... enable row level security per table. New tables created through the dashboard prompt you to enable it, but tables created via raw SQL won't have it unless you add the statement.
Does the service_role key bypass RLS?
Yes. service_role has BYPASSRLS, so it ignores all policies. Keep it strictly server-side and rotate it if it's ever exposed.
Why does my insert fail with "new row violates row-level security policy"?
Either there's no INSERT policy, or your WITH CHECK expression is false for the row being inserted — commonly because the client sent a user_id/organization_id the user isn't allowed to write. Default the ownership column to auth.uid() and validate it in the policy.
What's the difference between USING and WITH CHECK?
USING decides which existing rows a user can see/modify/delete. WITH CHECK validates the values of rows being inserted or updated. SELECT/DELETE use USING; INSERT uses WITH CHECK; UPDATE uses both.
How do I do role-based access control with RLS?
Store roles in app_metadata (surfaced as a JWT claim via a custom access token hook) or in a table like organization_members.role. Check them in policies with auth.jwt() ->> 'user_role' or a SECURITY DEFINER helper. Prefer table-driven roles when you need instant revocation.
Does RLS slow down queries?
It can, because policy expressions run per row. Mitigate it by wrapping auth calls in (select ...), indexing the columns policies filter on, scoping policies by role, and keeping helper functions stable. Well-indexed policies add negligible overhead.
Can anonymous users access data with RLS on?
Only if you write a policy that allows the anon role. With RLS enabled and no anon policy, unauthenticated requests get zero rows.
How do I avoid infinite recursion in policies?
Don't query a table from within its own policy. Move the lookup into a SECURITY DEFINER function (with set search_path = ''), which bypasses RLS internally and breaks the cycle.
Conclusion
Row Level Security is the part of Supabase you cannot skip. Because the API is public and the anon key ships to the browser, your policies are the security boundary — not your frontend, not your API layer. Enable RLS on every table, default to deny, scope each policy by role, push membership logic into SECURITY DEFINER helpers, index the columns your policies touch, and test the negative cases as rigorously as the happy path.
Get those habits right and RLS becomes invisible infrastructure: users only ever see their own data, multi-tenant boundaries hold, and the database enforces your rules no matter what the client tries to send. Then scan the layer in front of it, and you've covered both the data and the perimeter.
Internal linking suggestions
/blog/supabase-security-best-practices— anchor: "Supabase security best practices"/featuresor/scanners— anchor: "scan your application"/blog/owasp-zap-vs-codesec— anchor: "automated security scanning"/(CodeSec home) — anchor: "CodeSec"
External references
- Supabase — Row Level Security: https://supabase.com/docs/guides/database/postgres/row-level-security
- Supabase — Custom Access Token Hook: https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook
- Supabase — RLS performance recommendations: https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices
- PostgreSQL — CREATE POLICY: https://www.postgresql.org/docs/current/sql-createpolicy.html
- PostgreSQL — Row Security Policies: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
- pgTAP — Unit testing for Postgres: https://pgtap.org/