Row-Level Security (RLS)
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Per-row access rules enforced by the database itself. Write the rule once in SQL; every query is filtered automatically. Supabase’s killer feature, Postgres’s superpower.
In plain English
Without Row-Level Security, here’s what a typical app looks like:
- Every table contains data for many users
- Your backend code is responsible for filtering: “only show rows where user_id matches the logged-in user”
- One forgotten filter → users see other users’ data
The problem with this model: the database is willing to give you anyone’s data; the backend is the only gate. One bug, one missed authorization check, and the gate is open.
RLS flips the model. You write rules directly on the database table: “a user can SELECT a row only if the row’s user_id matches their session’s user_id.” After that:
- Every query to that table is automatically filtered
- Whether the query comes from your backend, a Supabase SDK call, an admin tool, a script — same rules apply
- “Forgetting to filter” becomes impossible because the database does it for you
RLS is a Postgres feature (since 9.5, 2016). Supabase’s auth model uses RLS as the core authorization mechanism: Supabase Auth identifies who the user is via JWT; RLS policies use auth.uid() to know who’s asking; the database enforces the rules.
If you remember one thing about Supabase: enable RLS on every table that holds user data. Then write policies. This is the single most important security practice in the platform.
Why it matters
- Defense in depth. Even if your backend has bugs, RLS still protects.
- Same protection from anywhere. Backend code, scripts, admin tools, Supabase SDK from the browser — all subject to the same rules.
- Easier auditing. All access rules live in SQL alongside the table. One place to read.
- The Supabase-on-the-frontend model is only safe with RLS. Without RLS, the
anonkey in your browser bundle would mean every table is publicly readable. - You can’t accidentally leak. A forgotten WHERE clause? Doesn’t matter; the database adds it.
How RLS works in Postgres
Two steps:
1. Enable RLS on the table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;After this, no rows are accessible to non-superuser connections. Default deny.
If you forget step 2 (writing policies), even your own queries from the SDK return empty arrays. That’s the system working — it just needs the policy to grant access.
2. Write policies
A policy is a rule: “for this operation, allow rows that match this condition.”
CREATE POLICY "Authors can read their own posts"
ON posts
FOR SELECT
USING (auth.uid() = author_id);Breaking this down:
ON posts— which tableFOR SELECT— which operation (SELECT, INSERT, UPDATE, DELETE, or ALL)USING (...)— which rows can be operated onauth.uid()— Supabase helper that returns the current user’s UUID from the JWT
When a user queries SELECT * FROM posts, Postgres effectively rewrites it to SELECT * FROM posts WHERE auth.uid() = author_id. Other users’ rows are invisible.
USING vs WITH CHECK
Some operations need two clauses:
| Operation | USING | WITH CHECK |
|---|---|---|
| SELECT | Which rows can be read | n/a |
| UPDATE | Which rows can be targeted | What new state is allowed |
| DELETE | Which rows can be deleted | n/a |
| INSERT | n/a | Which new rows are allowed |
USING says “you can operate on rows that match this.” WITH CHECK says “the result of your operation must match this.”
Example for UPDATE: “user can update their own posts, but can’t change author_id”:
CREATE POLICY "Authors update own"
ON posts FOR UPDATE
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);The USING restricts which rows show up for update. The WITH CHECK ensures the row still passes the rule after the update (preventing a user from “updating” to change author_id away from themselves).
Common policy patterns
Public read
CREATE POLICY "Anyone reads published posts"
ON posts FOR SELECT
USING (published = true);Anyone (including anonymous users) can see published posts.
Owner-only access
CREATE POLICY "Owners read own data"
ON private_notes FOR SELECT
USING (auth.uid() = user_id);Only the owner sees their notes. Everyone else gets nothing.
Owner-write, public-read
CREATE POLICY "Anyone reads" ON posts FOR SELECT USING (true);
CREATE POLICY "Authors create" ON posts FOR INSERT WITH CHECK (auth.uid() = author_id);
CREATE POLICY "Authors update" ON posts FOR UPDATE USING (auth.uid() = author_id) WITH CHECK (auth.uid() = author_id);
CREATE POLICY "Authors delete" ON posts FOR DELETE USING (auth.uid() = author_id);Pattern: anyone reads; only the author writes.
Role-based
CREATE POLICY "Admins read everything"
ON posts FOR SELECT
USING (
auth.uid() IN (SELECT user_id FROM admin_users)
);A separate table tracks admin users. Policy joins to it.
Org membership
CREATE POLICY "Team members read org data"
ON projects FOR SELECT
USING (
org_id IN (
SELECT org_id FROM org_members WHERE user_id = auth.uid()
)
);User can see projects from any org they belong to.
Combined logic
CREATE POLICY "Read own posts or public posts"
ON posts FOR SELECT
USING (auth.uid() = author_id OR published = true);OR within USING is fine.
Time-bounded
CREATE POLICY "Edit window after creation"
ON posts FOR UPDATE
USING (
auth.uid() = author_id
AND created_at > now() - interval '24 hours'
);Users can edit their posts only within 24 hours of creation.
Multiple policies on the same table
You can have multiple policies. They are combined with OR for the same operation.
CREATE POLICY "Authors read own" ON posts FOR SELECT USING (auth.uid() = author_id);
CREATE POLICY "Anyone reads published" ON posts FOR SELECT USING (published = true);Together: a user can SELECT rows that are either theirs OR published. Most permissive policy wins.
PERMISSIVE (default) vs RESTRICTIVE policies:
- PERMISSIVE — combined with OR (more grants more access)
- RESTRICTIVE — combined with AND (more restricts further)
Most policies are PERMISSIVE. RESTRICTIVE adds extra constraints on top.
The service_role bypass
Postgres has a concept of role. The Supabase anon and authenticated roles are subject to RLS. The service_role is not — it bypasses all policies by design.
This is intentional: server-side admin tasks need to read/write across users. The trade-off: the service_role key is god-mode and must NEVER be exposed to client code.
In Next.js: server-only env var, no NEXT_PUBLIC_ prefix. See Secrets management.
A concrete example: a multi-user notes app
-- Schema
CREATE TABLE notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
title text NOT NULL,
content text,
is_public boolean DEFAULT false,
created_at timestamptz DEFAULT now()
);
-- Enable RLS (CRITICAL — don't skip)
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- Anyone can read public notes
CREATE POLICY "Read public notes"
ON notes FOR SELECT
USING (is_public = true);
-- Authenticated users can read their own notes
CREATE POLICY "Read own notes"
ON notes FOR SELECT
USING (auth.uid() = user_id);
-- Authenticated users can create notes (as themselves)
CREATE POLICY "Create own notes"
ON notes FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can update their own
CREATE POLICY "Update own notes"
ON notes FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Users can delete their own
CREATE POLICY "Delete own notes"
ON notes FOR DELETE
USING (auth.uid() = user_id);After this:
- Anonymous visitors see only
is_public = truenotes - Logged-in users see their own + public
- Users can only create notes as themselves, edit/delete only their own
- No backend logic needed for any of this — the database enforces
Your frontend can use the Supabase SDK directly with the anon key, and none of these rules can be bypassed by client manipulation.
Testing RLS
Supabase Studio has a “Test policy” feature in the SQL editor. Set the auth context, run the query, see what’s returned:
SET local "request.jwt.claims" = '{ "sub": "user-uuid-here" }';
SELECT * FROM notes;
RESET ALL;Or via the SDK, log in as a test user and run queries — confirm you see what you should and don’t see what you shouldn’t.
Test with multiple user accounts. A policy that looks right may have a bug only visible across users.
When NOT to use RLS
- Admin / internal tools. Use service_role + your own checks.
- Tables with no per-user dimension. Configuration tables, lookup tables — sometimes “everyone can read” is the whole story.
- Highly complex authorization. RLS in pure SQL gets hard for things like “approve flow with multiple stages.” Consider domain logic in your application + simpler RLS as a backstop.
But for tables holding user data, RLS is the right answer 95% of the time.
Performance considerations
RLS adds a WHERE clause to every query. This can have performance impact:
- Indexes matter. If your policy says
WHERE auth.uid() = user_id, you want an index onuser_id. Otherwise full table scan per query. - Complex policies are slow. A policy that joins to other tables, runs sub-queries, etc., adds that cost to every query. Keep policies simple.
auth.uid()is fast. Reads the JWT claim. Negligible cost.
For a Supabase project of typical size (under millions of rows per table), RLS overhead is invisible. At large scale, profile.
Common gotchas
-
Forgetting to enable RLS. A table without RLS enabled is wide-open to the
anonkey. Supabase Studio nags you about this; listen. -
Enabling RLS but no policies. Default deny means even legitimate access fails. After enabling, write the policies you want.
-
Confusing USING with WITH CHECK. USING for which rows are visible; WITH CHECK for what new state is allowed. Easy to mix up.
-
Service_role on client. Catastrophic. Use only server-side.
-
auth.uid()returns NULL for unauthenticated users. Policies likeauth.uid() = user_idfail (since NULL != anything). Anonymous access requiresUSING (true)policies. -
Policies not refreshed in long sessions. A user’s role changes mid-session — they keep old access until token refresh. Use short-lived JWTs.
-
JWT claims as a security boundary. What’s in the JWT can’t be trusted blindly — it was issued by your auth at login time. Re-check critical claims if behaviour can change.
-
Forgetting INSERT policies. People often write SELECT and UPDATE but forget INSERT. Then their app can’t insert anything because no INSERT policy permits.
-
WITH CHECK that’s too permissive. “auth.uid() = user_id” lets users INSERT as themselves but doesn’t prevent them from setting other fields. Validate field-level constraints separately if needed.
-
JOINs in policies. Allowed and useful, but slow. Make sure joined tables have proper indexes.
-
Policies on partitioned tables. Postgres partitions need policies on the parent and may need per-partition policies. Read docs carefully.
-
RLS doesn’t apply to superuser / postgres role. Direct DB admin can see everything. Expected, but be aware.
-
Triggers running as table owner bypass RLS. Triggers fire as the table owner role, which bypasses RLS by default. Use SECURITY INVOKER if you want trigger to respect calling user’s RLS.
-
USING (true)for “public.” Right pattern for genuinely public data. But verify there’s no PII in that table. -
RLS combined with views. Views have their own RLS interaction. By default views run with the view-creator’s permissions, potentially bypassing RLS. Use
SECURITY INVOKERviews in Postgres 15+. -
Bulk operations becoming slow. Bulk INSERT/UPDATE under RLS still checks each row. For data migration jobs, use service_role and skip RLS.
-
Realtime subscriptions. Supabase Realtime respects RLS for subscriptions. Make sure RLS policies allow your subscription pattern.
-
auth.userstable is special. You can’t add policies to it directly. To attach user-specific data, use a separateprofilestable with a foreign key toauth.users(id). -
Anonymous policies that look too generous. “USING (true)” for SELECT on a publicly-readable table is fine. “USING (true)” for UPDATE = anyone can update everything. Match the operation to the intended trust level.
-
Forgetting to RLS test before launching. Sign in as user A; do everything you’d expect. Sign in as user B; verify you can’t see A’s data. Without this, you don’t know if your policies are right.
See also
- What is a database? đźź©
- SQL — the language 🟩
- Postgres 🟩 — RLS is a Postgres feature
- Supabase 🟩 🟦 — RLS is central to its model
- Schema design 🟥
- Authentication vs authorization 🟩 — RLS is the AuthZ side
- SQL injection 🟩 — orthogonal but related
- Secrets management 🟩 — service_role discussion
- OWASP top 10 🟩 🟦 — RLS mitigates A01
- How-to: Set up Supabase đźź©
- How-to: Add Supabase auth đźź©
- How-to: Enable RLS đźź©
- Glossary: RLS (Row-Level Security)