Row-Level Security is Supabase's most powerful feature and its most misunderstood. Developers enable it, write a policy, and assume they're done. Then six months later they discover their users can read each other's data — or worse, they've silently locked everyone out of their own data.

This guide covers RLS from first principles: what it actually does, how auth.uid() connects to your policies, the USING vs WITH CHECK distinction that trips everyone up, and the production patterns you need for multi-tenant AI applications.

What Row-Level Security Actually Does

RLS is a PostgreSQL feature — Supabase didn't invent it. When you enable RLS on a table, PostgreSQL evaluates a policy expression for every row that any query touches. If the expression returns false, that row is invisible to the query. No error is thrown — the row simply doesn't exist as far as the caller is concerned.

The default state after enabling RLS is deny-all: no rows are returned to anyone. This is the #1 source of confusion. Developers enable RLS, lose access to their own data, and assume something is broken.

Enabling RLS with no policies means ZERO rows are returned for any query — including your own authenticated requests. Always write your SELECT policy immediately after enabling RLS.
-- Enable RLS on a table
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
 
-- Until you add a policy, this returns 0 rows:
SELECT * FROM documents; -- returns nothing
 
-- Add a basic policy immediately:
CREATE POLICY "Users see their own documents"
  ON documents FOR SELECT
  USING (auth.uid() = user_id);

How auth.uid() Actually Works

auth.uid() is a Supabase-specific PostgreSQL function that returns the UUID of the currently authenticated user. It reads from the JWT that Supabase validates on every request.

When a client makes a request via the Supabase JS client, the SDK automatically attaches the user's access token to the Authorization header. Supabase's PostgREST layer validates this JWT and sets the auth.uid() context for that database session.

-- auth.uid() returns the UUID from the verified JWT
-- It returns NULL for unauthenticated (anon) requests
 
SELECT auth.uid(); -- returns your user UUID if authenticated
 
-- Check current role (anon vs authenticated)
SELECT current_user; -- 'anon' or 'authenticated'
auth.uid() returns NULL for requests using the anon key without a logged-in user. Always test your policies with both authenticated and unauthenticated requests.

USING vs WITH CHECK: The Distinction That Matters

Every RLS policy has two expressions: USING and WITH CHECK. Most tutorials only show USING. The difference matters enormously in production.

Expression Controls Applies To
USING Which rows are visible SELECT, UPDATE (existing row), DELETE
WITH CHECK Which rows can be written INSERT, UPDATE (new row values)

For a complete policy that prevents data smuggling — where a user inserts a row with another user's ID — you need both:

-- Incomplete: only controls visibility, not writes
CREATE POLICY "Users see their documents"
  ON documents FOR SELECT
  USING (auth.uid() = user_id);
 
-- Complete: controls both reads AND writes
CREATE POLICY "Users manage their own documents"
  ON documents
  USING (auth.uid() = user_id)          -- what you can read/delete
  WITH CHECK (auth.uid() = user_id);    -- what you can insert/update
Without WITH CHECK on INSERT, a user can insert a row with user_id set to someone else's UUID — effectively writing data into another user's namespace. Always include WITH CHECK on INSERT and UPDATE policies.

Multi-Tenant RLS: Organisation-Scoped Data

Most AI applications aren't single-user — they're multi-tenant. You have organisations or workspaces, and users belong to one or more of them. Here's the pattern that scales.

Schema Setup

-- organisations table
CREATE TABLE organisations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
-- membership junction table
CREATE TABLE org_members (
  org_id UUID REFERENCES organisations(id) ON DELETE CASCADE,
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
  PRIMARY KEY (org_id, user_id)
);
 
-- your data table
CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES organisations(id) ON DELETE CASCADE,
  created_by UUID REFERENCES auth.users(id),
  content TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

RLS Policies for Organisation-Scoped Data

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
 
-- SELECT: members of the org can read all org documents
CREATE POLICY "Org members can read documents"
  ON documents FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM org_members
      WHERE org_members.org_id = documents.org_id
        AND org_members.user_id = auth.uid()
    )
  );
 
-- INSERT: members can create documents in their org
CREATE POLICY "Org members can create documents"
  ON documents FOR INSERT
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM org_members
      WHERE org_members.org_id = documents.org_id
        AND org_members.user_id = auth.uid()
    )
  );
 
-- DELETE: only admins can delete
CREATE POLICY "Org admins can delete documents"
  ON documents FOR DELETE
  USING (
    EXISTS (
      SELECT 1 FROM org_members
      WHERE org_members.org_id = documents.org_id
        AND org_members.user_id = auth.uid()
        AND org_members.role = 'admin'
    )
  );

Performance: The EXISTS Subquery Problem

The org_members EXISTS subquery runs on every row evaluation. For a table with 100,000 documents owned by 50 orgs, this can mean 100,000 subquery executions per SELECT. At scale this tanks query performance.

Solution 1: Index org_members Correctly

-- Without this index, the EXISTS subquery does a sequential scan
CREATE INDEX idx_org_members_user_org
  ON org_members (user_id, org_id);
 
-- Also index the foreign key on your data table
CREATE INDEX idx_documents_org_id ON documents (org_id);

Solution 2: JWT Custom Claims (Best for Performance)

The fastest approach is to embed the user's org memberships directly in the JWT, so no subquery is needed at all. Supabase lets you add custom claims via a PostgreSQL hook.

-- Create a function that adds org_ids to the JWT
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB LANGUAGE plpgsql AS $$
DECLARE
  claims JSONB;
  org_ids UUID[];
BEGIN
  -- Fetch this user's org memberships
  SELECT ARRAY_AGG(org_id) INTO org_ids
  FROM org_members
  WHERE user_id = (event->>'user_id')::UUID;
 
  claims := event->'claims';
  claims := jsonb_set(claims, '{org_ids}', to_jsonb(org_ids));
  RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
 
-- Register the hook in Supabase Dashboard:
-- Authentication > Hooks > Custom Access Token Hook
-- Now your policy can check the JWT claim directly — no subquery
CREATE POLICY "Org members can read documents (JWT)"
  ON documents FOR SELECT
  USING (
    org_id = ANY(
      ARRAY(SELECT jsonb_array_elements_text(
        (auth.jwt()->'org_ids')
      )::UUID)
    )
  );
JWT custom claims eliminate per-row subqueries entirely. For applications with large datasets and frequent reads, this is the correct production approach.

Testing RLS Policies Before They Hit Production

Supabase's SQL editor lets you test policies as a specific user by setting the JWT context manually. This is essential before deploying schema changes.

-- Test as a specific user (replace UUID with a real user ID)
SET LOCAL role = 'authenticated';
SET LOCAL request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
 
-- Now run your query — it will be evaluated with that user's context
SELECT * FROM documents;
 
-- Test as anonymous
SET LOCAL role = 'anon';
SELECT * FROM documents; -- should return 0 rows if anon is blocked

Using the Supabase Policy Simulator

In the Supabase Dashboard, navigate to Table Editor > your table > RLS Policies. Each policy has a "Test" button that lets you simulate queries as different users without writing SQL. Use this to verify your policies before every schema migration.

Common Mistakes and How to Fix Them

Mistake Symptom Fix
RLS enabled, no policies All queries return 0 rows Add a SELECT policy immediately after enabling RLS
Missing WITH CHECK on INSERT Users can insert rows with arbitrary user_id Add WITH CHECK (auth.uid() = user_id) to every INSERT policy
No index on join columns Slow queries as table grows Index org_members(user_id, org_id) and foreign keys
Policy references unindexed column Full table scans on every request EXPLAIN ANALYZE your queries; add missing indexes
Forgetting service role bypasses RLS Backend code reads everything Use service role only in trusted server-side code; never expose it client-side

The Service Role: When RLS Is Bypassed

Supabase has two key types: the anon key (respects RLS) and the service role key (bypasses RLS entirely). The service role key is for trusted server-side operations only — seeding data, admin scripts, and background jobs that need unrestricted access.

Never expose the service role key in client-side code or environment variables accessible to the browser. It bypasses all RLS policies. Treat it like a database root password.
# Server-side only — uses service role key to bypass RLS
from supabase import create_client
 
supabase_admin = create_client(
    os.environ['SUPABASE_URL'],
    os.environ['SUPABASE_SERVICE_ROLE_KEY']  # server-side only
)
 
# Client-side — uses anon key, RLS applies
supabase_client = create_client(
    os.environ['SUPABASE_URL'],
    os.environ['NEXT_PUBLIC_SUPABASE_ANON_KEY']  # safe for browser
)

RLS for AI Applications: Vector Store Isolation

If you're using Supabase's pgvector extension for RAG, your embeddings table needs RLS too. Without it, semantic search returns results from all users' documents — a serious data leak.

-- Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
 
-- Embeddings table with org isolation
CREATE TABLE document_embeddings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
  org_id UUID REFERENCES organisations(id) ON DELETE CASCADE,
  embedding VECTOR(1536),
  content_chunk TEXT
);
 
ALTER TABLE document_embeddings ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY "Org members see their embeddings"
  ON document_embeddings FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM org_members
      WHERE org_members.org_id = document_embeddings.org_id
        AND org_members.user_id = auth.uid()
    )
  );
 
-- Semantic search is now automatically org-scoped:
SELECT content_chunk, 1 - (embedding <=> query_embedding) AS similarity
FROM document_embeddings
ORDER BY embedding <=> query_embedding
LIMIT 5;
-- RLS ensures this only returns chunks from the user's org
RLS applies to pgvector similarity searches automatically. The <=> operator query goes through normal PostgreSQL row filtering — so your RLS policies protect embeddings the same way they protect regular rows.

Summary

  • Enable RLS immediately followed by a SELECT policy — the default is deny-all
  • Always include WITH CHECK on INSERT and UPDATE policies to prevent data smuggling
  • Use EXISTS subqueries for org-scoped access; index the join columns
  • For high-traffic tables, use JWT custom claims to eliminate per-row subqueries
  • Test policies in the SQL editor using SET LOCAL role before deploying
  • The service role key bypasses RLS — never expose it to the client
  • pgvector similarity searches respect RLS — always enable RLS on your embeddings table
Metadata Value
Title Supabase Row-Level Security in Production: The Complete Guide for Multi-Tenant Apps
Tool Supabase
Primary SEO keyword supabase row level security
Secondary keywords supabase RLS multi-tenant, supabase RLS policies, supabase RLS production, supabase pgvector RLS
Estimated read time 12 minutes
Research date 2026-04-14