- Published on
- · 19 min read
Updated
Row Level Security in Supabase: Complete Guide for Next.js with @supabase/ssr (2026)
TL;DR: Row Level Security (RLS) is Supabase's primary security mechanism — it controls who can read, insert, update, and delete rows at the database level. This guide walks through setting up @supabase/ssr with Next.js, writing CRUD policies with auth.uid(), building multi-tenant access patterns, optimizing policy performance with indexes, and debugging the most common RLS mistakes. If you're building anything on Supabase, RLS isn't optional — it's your first line of defense.
Table of Contents
- What is Row Level Security?
- Why RLS Matters in Supabase
- Setting Up @supabase/ssr with Next.js
- Auth Middleware Configuration
- Creating Server and Client Supabase Utilities
- Writing RLS Policies: SELECT, INSERT, UPDATE, DELETE
- Common Policy Patterns
- Multi-Tenant RLS
- Performance Optimization
- Testing RLS Policies
- Common Mistakes and Debugging
- RLS vs Application-Level Security
What is Row Level Security?
Row Level Security (RLS) is a PostgreSQL feature that restricts which rows a user can access within a table. Instead of controlling access at the table level (all or nothing), RLS lets you define policies that filter rows based on the identity and attributes of the user making the query.
When RLS is enabled on a table, PostgreSQL appends your policy conditions to every query automatically. A SELECT * FROM posts from an authenticated user silently becomes SELECT * FROM posts WHERE author_id = '<their-user-id>'. The user never sees rows they don't own — the database enforces it, not your application code.
RLS policies consist of two clauses:
USING— filters which existing rows the user can see (applies toSELECT,UPDATE,DELETE)WITH CHECK— validates which new or modified row values are allowed (applies toINSERT,UPDATE)
The key function in Supabase policies is auth.uid(), which extracts the current user's ID from their JWT token. This is how policies know who is making the request.
Why RLS Matters in Supabase
Supabase exposes your PostgreSQL database directly to the client via the PostgREST API. The anon key that ships with every project lets any client query your database — RLS is the only thing standing between your data and an unauthorized request.
This is fundamentally different from traditional architectures where a backend server mediates all database access. In Supabase:
- RLS is default-deny. When you enable RLS on a table and add no policies, zero rows are returned. No accidental data leaks.
- The
anonkey is public. Anyone can extract it from your client-side code. RLS policies are what prevent that key from reading everything. - The
service_rolekey bypasses RLS entirely. This key should never appear in client-side code — it's for server-side admin operations only.
In January 2025, over 170 applications built with AI tools were found to have fully exposed databases because developers didn't enable RLS. Supabase now sends email alerts when tables have RLS disabled, but the responsibility is still yours.
RLS isn't a replacement for application-level validation — it's a safety net. Even if your API route has a bug that forgets to filter by user_id, the database refuses to return unauthorized rows. Defense in depth.
Setting Up @supabase/ssr with Next.js
The @supabase/ssr package replaces the deprecated @supabase/auth-helpers-nextjs. It handles cookie-based authentication for server-rendered frameworks, which is critical for RLS — your database needs a valid JWT on every request to know who the user is.
Install the required packages:
pnpm add @supabase/supabase-js @supabase/ssrAdd your Supabase credentials to .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...The anon key is safe to expose — RLS policies are what protect your data. Never expose the service_role key in client-side code.
Auth Middleware Configuration
Server Components in Next.js can't write cookies. You need middleware to refresh expired auth tokens and pass them forward. Create middleware.ts in your project root (or src/ if using the src directory):
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Do not use getSession() here — it doesn't revalidate the token.
// getUser() sends a request to Supabase Auth to verify the JWT.
const {
data: { user },
} = await supabase.auth.getUser()
// Redirect unauthenticated users away from protected routes
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: [
// Match all routes except static files and Next.js internals
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}The middleware does two things: it refreshes the auth token by calling getUser() (which validates the JWT against Supabase Auth), and it passes the refreshed token forward via cookies — both to Server Components (request.cookies.set) and to the browser (supabaseResponse.cookies.set).
Important: Never use getSession() in server-side code. It reads the JWT from cookies without revalidating it. A tampered or expired token would still appear valid. Always use getUser(), which makes a network request to Supabase Auth to verify the token is legitimate.
Creating Server and Client Supabase Utilities
Create utility functions that configure the Supabase client correctly for each context.
Server Client
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// setAll can be called from a Server Component where cookies
// can't be set. This is safe to ignore if middleware is
// refreshing sessions.
}
},
},
}
)
}Browser Client
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}Use the server client in Server Components, Route Handlers, and Server Actions. Use the browser client in Client Components. The middleware ensures both contexts have a valid, refreshed JWT — which means auth.uid() resolves correctly in your RLS policies.
// app/dashboard/page.tsx (Server Component)
import { createClient } from '@/lib/supabase/server'
export default async function Dashboard() {
const supabase = await createClient()
// RLS automatically filters — only returns rows owned by the authenticated user
const { data: posts } = await supabase.from('posts').select('*')
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}Writing RLS Policies: SELECT, INSERT, UPDATE, DELETE
Policies are defined per-table, per-operation. Enable RLS first, then add policies for each CRUD operation. For a deeper dive into Supabase's database features, see our guide on Supabase Edge Functions.
Enable RLS
alter table public.posts enable row level security;Once enabled with no policies, the table returns zero rows to all users (except service_role).
SELECT Policy
-- Users can read their own posts
create policy "Users can view own posts"
on public.posts
for select
to authenticated
using ( (select auth.uid()) = author_id );INSERT Policy
-- Users can only insert posts where they are the author
create policy "Users can create own posts"
on public.posts
for insert
to authenticated
with check ( (select auth.uid()) = author_id );INSERT uses WITH CHECK instead of USING because there are no existing rows to filter — it validates the row being created.
UPDATE Policy
-- Users can update their own posts
create policy "Users can update own posts"
on public.posts
for update
to authenticated
using ( (select auth.uid()) = author_id )
with check ( (select auth.uid()) = author_id );UPDATE uses both clauses: USING determines which rows can be selected for updating, WITH CHECK validates the new values after the update.
DELETE Policy
-- Users can delete their own posts
create policy "Users can delete own posts"
on public.posts
for delete
to authenticated
using ( (select auth.uid()) = author_id );DELETE only needs USING — there's no new row data to validate.
Targeting Roles
Each policy can target specific PostgreSQL roles. Supabase uses two primary roles:
anon— unauthenticated requests (public access)authenticated— logged-in users with a valid JWT
-- Public read access for all users (including anonymous)
create policy "Anyone can view published posts"
on public.posts
for select
to anon, authenticated
using ( published = true );Common Policy Patterns
Own Data Only
The most common pattern — users access only their own rows:
create policy "own_data_select" on public.profiles
for select to authenticated
using ( (select auth.uid()) = user_id );
create policy "own_data_insert" on public.profiles
for insert to authenticated
with check ( (select auth.uid()) = user_id );
create policy "own_data_update" on public.profiles
for update to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "own_data_delete" on public.profiles
for delete to authenticated
using ( (select auth.uid()) = user_id );Public Read, Authenticated Write
Common for content platforms — anyone can read, only authors can modify:
create policy "public_read" on public.articles
for select to anon, authenticated
using ( status = 'published' );
create policy "author_insert" on public.articles
for insert to authenticated
with check ( (select auth.uid()) = author_id );
create policy "author_update" on public.articles
for update to authenticated
using ( (select auth.uid()) = author_id )
with check ( (select auth.uid()) = author_id );Admin Override
Use JWT claims to grant admins unrestricted access:
create policy "admin_full_access" on public.posts
for all to authenticated
using (
(select auth.jwt() ->> 'user_role') = 'admin'
);Set user_role in the user's app_metadata via the Supabase Admin API or dashboard — app_metadata can't be modified by the user, making it safe for authorization.
Restrictive Policies
By default, policies are permissive — if any permissive policy passes, access is granted. Restrictive policies must all pass in addition to at least one permissive policy:
-- Permissive: user owns the row
create policy "own_data" on public.documents
for select to authenticated
using ( (select auth.uid()) = owner_id );
-- Restrictive: must also have completed MFA
create policy "require_mfa" on public.documents
as restrictive
for select to authenticated
using ( (select auth.jwt() ->> 'aal') = 'aal2' );This requires both conditions — the user owns the row and they've completed multi-factor authentication.
Multi-Tenant RLS
Multi-tenant applications need policies that check organization membership, not just individual user IDs.
Schema Setup
create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz default now()
);
create table public.org_members (
id uuid primary key default gen_random_uuid(),
org_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'
created_at timestamptz default now(),
unique (org_id, user_id)
);
create table public.projects (
id uuid primary key default gen_random_uuid(),
org_id uuid references public.organizations(id) on delete cascade,
name text not null,
created_at timestamptz default now()
);
-- Indexes for RLS policy performance
create index idx_org_members_user on public.org_members(user_id);
create index idx_org_members_org on public.org_members(org_id);
create index idx_projects_org on public.projects(org_id);Organization-Scoped Policies
alter table public.projects enable row level security;
-- Members can view projects in their organizations
create policy "org_members_select_projects"
on public.projects
for select to authenticated
using (
org_id in (
select org_id from public.org_members
where user_id = (select auth.uid())
)
);
-- Only admins and owners can create projects
create policy "org_admins_insert_projects"
on public.projects
for insert to authenticated
with check (
org_id in (
select org_id from public.org_members
where user_id = (select auth.uid())
and role in ('admin', 'owner')
)
);
-- Only admins and owners can delete projects
create policy "org_admins_delete_projects"
on public.projects
for delete to authenticated
using (
org_id in (
select org_id from public.org_members
where user_id = (select auth.uid())
and role in ('admin', 'owner')
)
);Security Definer Helper for Complex Lookups
For multi-tenant queries that hit multiple join tables, wrap the lookup in a security definer function. This runs with the function owner's permissions, bypassing RLS on the lookup tables and avoiding cascading policy checks:
create or replace function private.user_org_ids()
returns setof uuid
language sql
security definer
set search_path = ''
as $$
select org_id
from public.org_members
where user_id = (select auth.uid())
$$;
-- Simpler, faster policy
create policy "org_members_select"
on public.projects
for select to authenticated
using ( org_id in (select private.user_org_ids()) );Performance Optimization
RLS policies run on every query. Poorly written policies can turn a 5ms query into a 500ms query. The Supabase performance guide covers this in detail.
Index Every Column Used in Policies
This is the single most impactful optimization. Without an index, every policy check triggers a sequential scan:
-- If your policy checks author_id, index it
create index idx_posts_author on public.posts(author_id);
-- For org-based policies, index the org_id column
create index idx_projects_org on public.projects(org_id);
-- For the org_members lookup table
create index idx_org_members_user on public.org_members(user_id);On a table with 100k+ rows, adding an index on the policy column can improve performance by over 100x according to Supabase's benchmarks.
Wrap auth.uid() in a Subselect
Always call auth.uid() inside (select auth.uid()) rather than bare. This tells the PostgreSQL query planner to evaluate it once as an initPlan and cache the result, instead of calling the function for every row:
-- Slow: auth.uid() called per row
create policy "slow" on posts
for select using ( auth.uid() = author_id );
-- Fast: auth.uid() evaluated once and cached
create policy "fast" on posts
for select using ( (select auth.uid()) = author_id );Use IN Instead of Joins
Restructure policies to use IN with a subquery instead of joining the source table to a lookup table. This lets the planner fetch the allowed IDs once:
-- Better: subquery with IN
create policy "team_access" on public.documents
for select to authenticated
using (
team_id in (
select team_id from public.team_members
where user_id = (select auth.uid())
)
);Measure with EXPLAIN ANALYZE
Use the Supabase SQL Editor to check how policies affect query plans:
explain analyze
select * from public.posts
where author_id = 'some-user-id';Look for sequential scans on large tables and policy-related filter steps. If a query exceeds 50ms on your typical data size, optimize the policy or add missing indexes.
Avoid Multiple Permissive Policies per Operation
PostgreSQL ORs permissive policies together. Multiple overlapping SELECT policies create complex query plans. Consolidate where possible:
-- Instead of two separate policies:
-- Policy 1: own posts
-- Policy 2: team posts
-- Combine into one:
create policy "accessible_posts" on public.posts
for select to authenticated
using (
(select auth.uid()) = author_id
or team_id in (
select team_id from public.team_members
where user_id = (select auth.uid())
)
);Testing RLS Policies
SQL-Level Testing
Test policies directly in the SQL Editor by impersonating different roles:
-- Test as a specific authenticated user
set role authenticated;
set request.jwt.claims to '{"sub": "user-id-123", "role": "authenticated"}';
-- This should only return rows where author_id = 'user-id-123'
select * from public.posts;
-- Verify another user can't see those rows
set request.jwt.claims to '{"sub": "user-id-456", "role": "authenticated"}';
select * from public.posts;
-- Test as anonymous
set role anon;
select * from public.posts; -- Should return nothing (or only public rows)
-- Reset
reset role;Client-Side Integration Testing
Write tests that sign in as different users and verify they only see their own data:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// Sign in as User A
await supabase.auth.signInWithPassword({
email: 'user-a@example.com',
password: 'test-password',
})
const { data: postsA } = await supabase.from('posts').select('*')
// Assert: all posts have author_id = User A's ID
// Sign in as User B
await supabase.auth.signInWithPassword({
email: 'user-b@example.com',
password: 'test-password',
})
const { data: postsB } = await supabase.from('posts').select('*')
// Assert: postsB does NOT contain any of User A's postsUsing the Supabase Dashboard
The Supabase dashboard Table Editor runs queries as service_role, which bypasses RLS. This is useful for verifying data exists when a client query returns empty results — if you see data in the Table Editor but not from your app, your RLS policies are blocking the request.
The Auth section shows all users and their IDs, which you can cross-reference with policy conditions.
Common Mistakes and Debugging
Enabled RLS, No Policies = No Data
This is the #1 issue. RLS is default-deny: once enabled, every operation returns empty results unless a policy explicitly grants access. If your queries suddenly return nothing after enabling RLS, check that you've created policies for each operation you need.
Forgetting to Enable RLS
The opposite problem — your table has no RLS, and every row is accessible to anyone with the anon key. Always enable RLS on every table in the public schema. Supabase now warns you when tables have RLS disabled, but don't rely on alerts.
You can auto-enable RLS on new tables with an event trigger:
-- Automatically enable RLS on every new table in the public schema
create or replace function public.auto_enable_rls()
returns event_trigger
language plpgsql
security definer
as $$
declare
cmd record;
begin
for cmd in
select * from pg_event_trigger_ddl_commands()
where command_tag in ('CREATE TABLE', 'CREATE TABLE AS')
and schema_name = 'public'
loop
execute format('alter table %s enable row level security', cmd.object_identity);
end loop;
end;
$$;
create event trigger ensure_rls on ddl_command_end
when tag in ('CREATE TABLE', 'CREATE TABLE AS')
execute function public.auto_enable_rls();Using getSession() Instead of getUser() on the Server
getSession() reads the JWT from cookies without verifying it. A tampered token looks valid. Always use getUser() in middleware and server-side code — it sends a request to Supabase Auth to verify the token.
Missing Indexes on Policy Columns
Without indexes, every policy evaluation triggers a full table scan. On a table with 100k rows, this turns sub-millisecond lookups into multi-second queries. Create indexes on every column referenced in a policy's USING or WITH CHECK clause.
Confusing USING and WITH CHECK
USINGfilters existing rows (used bySELECT,UPDATE,DELETE)WITH CHECKvalidates new/modified row data (used byINSERT,UPDATE)
If your INSERT policy uses USING instead of WITH CHECK, inserts will always fail. UPDATE needs both — USING to select which rows can be updated, WITH CHECK to validate the new values.
The service_role Key in Client Code
If your service_role key is in any client-side bundle, your entire database is exposed. This key bypasses all RLS. Keep it exclusively in server-side environment variables (without the NEXT_PUBLIC_ prefix).
Multiple Permissive Policies Creating Unexpected Access
Permissive policies are OR'd together. If you have a broad policy like USING (true) for public read access alongside a user-specific policy, the broad policy dominates — every user sees every row.
RLS vs Application-Level Security
Both approaches have trade-offs. In production, use both — RLS as the safety net, application logic for complex business rules.
| Aspect | Row Level Security (RLS) | Application-Level Auth |
|---|---|---|
| Where it runs | Database engine | Application server |
| Bypass risk | Cannot be bypassed by app bugs | A single missed check exposes data |
| Performance | Adds overhead to every query | No DB overhead, but adds app latency |
| Complexity | SQL policies (can be hard to debug) | Code logic (easier to unit test) |
| Granularity | Row and column level | Any business logic |
| Works with direct DB access | Yes — protects PostgREST, SQL, etc. | No — only protects your API |
| Multi-tenant support | Built-in via policies | Must implement manually in every query |
| Audit trail | Policy definitions are in the schema | Spread across codebase |
| Best for | Supabase apps, multi-tenant, defense in depth | Complex business rules, external APIs |
The recommendation for Supabase apps: always enable RLS on every table. Use application-level checks for complex business logic that doesn't map cleanly to SQL policies (multi-step workflows, rate limiting, conditional access based on external state). RLS catches the bugs your application code misses.
RLS is one of the most powerful features in Supabase's security model. Combined with @supabase/ssr and proper middleware configuration, it creates a defense-in-depth architecture where your database protects itself — even if your application code has bugs. Start with simple user-scoped policies, add indexes, and expand to multi-tenant patterns as your app grows.
For more on Supabase capabilities, check out our guide on Supabase Edge Functions. The Supabase RLS documentation, PostgreSQL RLS docs, and Supabase performance advisors are essential references as you build out your policies.
