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?

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 to SELECT, UPDATE, DELETE)
  • WITH CHECK — validates which new or modified row values are allowed (applies to INSERT, 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:

  1. RLS is default-deny. When you enable RLS on a table and add no policies, zero rows are returned. No accidental data leaks.
  2. The anon key is public. Anyone can extract it from your client-side code. RLS policies are what prevent that key from reading everything.
  3. The service_role key 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/ssr

Add 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 posts

Using 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

  • USING filters existing rows (used by SELECT, UPDATE, DELETE)
  • WITH CHECK validates new/modified row data (used by INSERT, 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.

AspectRow Level Security (RLS)Application-Level Auth
Where it runsDatabase engineApplication server
Bypass riskCannot be bypassed by app bugsA single missed check exposes data
PerformanceAdds overhead to every queryNo DB overhead, but adds app latency
ComplexitySQL policies (can be hard to debug)Code logic (easier to unit test)
GranularityRow and column levelAny business logic
Works with direct DB accessYes — protects PostgREST, SQL, etc.No — only protects your API
Multi-tenant supportBuilt-in via policiesMust implement manually in every query
Audit trailPolicy definitions are in the schemaSpread across codebase
Best forSupabase apps, multi-tenant, defense in depthComplex 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.

Share: