Supabase Row Level Security Tutorial: The Complete Guide to Protecting Your Data

Supabase Row Level Security Tutorial: The Complete Guide to Protecting Your Data

If you are building an app with Supabase, your database is exposed to the internet by design. That is not a flaw — it is a feature. Supabase gives your frontend a direct connection to PostgreSQL through its auto-generated REST API, which means incredible developer experience and speed. But it also means that without proper security, anyone with your project URL and anon key can read, modify, or delete every row in your database.

Row Level Security (RLS) is how you prevent that. In this tutorial, you will learn exactly how RLS works in Supabase, how to write policies that actually protect your data, and how to avoid the mistakes that have caused real-world data breaches.

What is Row Level Security?

Row Level Security is a native PostgreSQL feature that lets you control access to individual rows in a database table. Instead of writing authorization logic in your application code, you define security rules — called policies — directly in the database. These policies act as invisible WHERE clauses that PostgreSQL automatically applies to every query.

Here is the key idea: when RLS is enabled on a table, PostgreSQL evaluates your policies before returning any data. If a user runs SELECT * FROM posts, they do not get every post. They only get the rows that match the policies you have defined. The same applies to INSERT, UPDATE, and DELETE operations.

Think of it like a bouncer at every table in your database. No matter how the query arrives — from your frontend, a REST API call, a server function, or even a direct connection — the bouncer checks the rules before letting any data through.

In the Supabase ecosystem, RLS is especially important because the anon key is public. Your Supabase client runs in the browser, and anyone can inspect network requests to find your project URL and API key. RLS is the mechanism that makes this safe.

Why RLS Matters for Your Supabase App

You might be thinking, "I will just handle authorization in my API layer." That works for traditional backends, but Supabase is different. Here is why RLS is not optional:

Your anon key is public. Every Supabase project has an anon key that ships to the browser. Without RLS, anyone who finds this key can query your entire database.

Defense in depth. Even if you have server-side checks, RLS provides a second layer of protection. If an API bug lets an unauthorized request through, the database itself blocks the access.

It protects against your own mistakes. As your app grows, new endpoints and features create new attack surfaces. RLS ensures that no matter how someone reaches your data, the same rules apply.

Real-world consequences are severe. In January 2025, over 170 apps built with the Lovable platform were found to have fully exposed databases (CVE-2025-48757). The root cause was simple: developers did not enable RLS. User data, credentials, and private information were accessible to anyone.

Getting Started with RLS in Supabase

Let us walk through the process of securing a table from scratch. We will use a todos table as our example.

Step 1: Create the Table

CREATE TABLE todos (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id) NOT NULL,
  title TEXT NOT NULL,
  is_complete BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT now()
);

Step 2: Enable Row Level Security

ALTER TABLE todos ENABLE ROW LEVEL SECURITY;

This single line changes everything. Once RLS is enabled, no one can access any rows through the Supabase API — not even authenticated users. RLS with zero policies means deny all by default. This is the safe starting point.

Step 3: Write Your First Policy

Let us allow users to read only their own todos:

CREATE POLICY "Users can view their own todos"
  ON todos
  FOR SELECT
  USING (auth.uid() = user_id);

Breaking this down:

  • FOR SELECT — this policy applies to read operations
  • USING — defines the condition that must be true for a row to be visible
  • auth.uid() — a Supabase helper function that returns the current user's ID from their JWT token

Now let us add policies for creating, updating, and deleting:

-- Users can create todos for themselves
CREATE POLICY "Users can insert their own todos"
  ON todos
  FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- Users can update their own todos
CREATE POLICY "Users can update their own todos"
  ON todos
  FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

-- Users can delete their own todos
CREATE POLICY "Users can delete their own todos"
  ON todos
  FOR DELETE
  USING (auth.uid() = user_id);

Notice the difference between USING and WITH CHECK:

  • USING filters which existing rows a user can see or target (used by SELECT, UPDATE, DELETE)
  • WITH CHECK validates the data being written (used by INSERT, UPDATE)

For UPDATE, you need both — USING controls which rows can be targeted, and WITH CHECK ensures the updated data still meets your rules.

Step 4: Add a Performance Index

CREATE INDEX idx_todos_user_id ON todos(user_id);

This is critical. Without this index, PostgreSQL must scan the entire table to evaluate auth.uid() = user_id for every row. On tables with thousands of rows, this can cause 100x slowdowns.

Step 5: Test Your Policies

Always test from the Supabase client SDK, not the SQL Editor. The SQL Editor runs as the postgres superuser and bypasses RLS entirely.

// This should only return the current user's todos
const { data, error } = await supabase
  .from('todos')
  .select('*');

// This should fail if user_id doesn't match
const { error: insertError } = await supabase
  .from('todos')
  .insert({ title: 'Test', user_id: 'some-other-user-id' });

Advanced RLS Policies

Once you are comfortable with basic policies, here are patterns you will need in production.

Role-Based Access Control

Many apps need admin users who can see all data. Create a helper function and use it in your policies:

-- Create a function to check if the current user is an admin
CREATE OR REPLACE FUNCTION public.is_admin()
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM profiles
    WHERE id = auth.uid()
    AND role = 'admin'
  );
$$ LANGUAGE sql SECURITY DEFINER;

-- Admin can view all todos, regular users see only their own
CREATE POLICY "Admin or owner can view todos"
  ON todos
  FOR SELECT
  USING (
    auth.uid() = user_id
    OR public.is_admin()
  );

The SECURITY DEFINER keyword is important here. It means the function runs with the permissions of the user who created it (usually the postgres superuser), so it can query the profiles table without being blocked by that table's own RLS policies.

Multi-Tenant Isolation

For SaaS apps where users belong to organizations, you need tenant-level isolation:

-- Team members can access their team's projects
CREATE POLICY "Team members can view projects"
  ON projects
  FOR SELECT
  USING (
    team_id IN (
      SELECT team_id FROM team_members
      WHERE user_id = auth.uid()
    )
  );

Performance tip: Notice the query pattern. We look up the team IDs for the current user first, then filter the projects table. This is significantly faster than the reverse pattern:

-- SLOW: Don't do this
USING (
  auth.uid() IN (
    SELECT user_id FROM team_members
    WHERE team_members.team_id = projects.team_id
  )
);

The slow version forces PostgreSQL to run a correlated subquery for every row in the projects table. The fast version gets the user's teams once and filters with an IN clause.

Service Role Bypass

Your server-side code sometimes needs to bypass RLS entirely — for example, when running background jobs or admin operations. Use the Supabase service role key for this:

import { createClient } from '@supabase/supabase-js';

// This client bypasses RLS completely
const supabaseAdmin = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY
);

// This will return ALL todos regardless of policies
const { data } = await supabaseAdmin.from('todos').select('*');

Never expose the service role key to the browser. It should only exist in server-side code, environment variables, and CI/CD pipelines.

One common gotcha: if you set an auth session on a service role client, the user's JWT overrides the service role in the Authorization header. Always keep your admin client separate and never call supabaseAdmin.auth.setSession() on it.

Public Read with Authenticated Write

For content like blog posts that anyone can read but only authors can edit:

-- Anyone can read published posts
CREATE POLICY "Public can view published posts"
  ON posts
  FOR SELECT
  USING (status = 'published');

-- Authors can view their own drafts
CREATE POLICY "Authors can view own drafts"
  ON posts
  FOR SELECT
  USING (auth.uid() = author_id);

-- Only the author can update their posts
CREATE POLICY "Authors can update own posts"
  ON posts
  FOR UPDATE
  USING (auth.uid() = author_id)
  WITH CHECK (auth.uid() = author_id);

Common Mistakes

1. Forgetting to Enable RLS

This is the number one security mistake in Supabase applications. If you create a table via SQL and forget ALTER TABLE ... ENABLE ROW LEVEL SECURITY, every single row is publicly accessible through the API. The Supabase Dashboard warns you about this, but SQL migrations do not.

Fix: Add RLS as part of every CREATE TABLE migration. Make it a habit.

CREATE TABLE my_table ( ... );
ALTER TABLE my_table ENABLE ROW LEVEL SECURITY;
-- Policies go here

2. Overly Permissive Policies

A policy like USING (true) gives everyone access to every row. Sometimes this is intentional for public data, but developers often add it during development and forget to tighten it before launch.

Similarly, be careful with user_metadata from the JWT. Users can modify their own metadata through the Supabase Auth API, so a policy like USING ((auth.jwt()->>'user_metadata')::jsonb->>'role' = 'admin') can be exploited by any authenticated user who sets their own role to admin.

Fix: Only use app_metadata (which users cannot modify) or look up roles from a database table.

3. Missing Indexes Kill Performance

RLS policies add conditions to every query. If those conditions reference unindexed columns, query performance degrades dramatically as your tables grow. This is the top performance killer for Supabase apps in production.

Fix: Run EXPLAIN ANALYZE on your queries and add indexes for every column referenced in your policies:

-- If your policy uses auth.uid() = user_id
CREATE INDEX idx_table_user_id ON my_table(user_id);

-- If your policy uses team_id in a subquery
CREATE INDEX idx_team_members_user_id ON team_members(user_id);

4. Forgetting That Views Bypass RLS

By default, views execute with the permissions of the view creator (usually the postgres superuser), which means they completely bypass RLS. If you create a view on a table with RLS and expose it through the API, the RLS policies are ignored.

Fix: In PostgreSQL 15 and above (which Supabase uses), set security_invoker = true:

CREATE VIEW my_view WITH (security_invoker = true) AS
  SELECT * FROM my_table;

FAQ

Does RLS affect performance?

Yes, but usually not in a way you will notice if you follow best practices. The biggest impact comes from missing indexes on columns used in policy conditions. With proper indexes, the overhead is minimal. Always benchmark with EXPLAIN ANALYZE on your actual data sizes, and aim for queries under 50ms.

Can I use RLS with Supabase Realtime?

Yes. Supabase Realtime respects RLS policies. When a client subscribes to changes on a table, they only receive events for rows they have access to according to your policies. This means you can safely use Realtime subscriptions without worrying about data leaking to unauthorized users.

What happens if I enable RLS but forget to add policies?

Your table becomes completely inaccessible through the API. All SELECT queries return empty results, and all INSERT, UPDATE, and DELETE operations fail silently. This is by design — RLS defaults to deny-all, which is the secure default. Your app will appear broken, but no data will be exposed. Add at least one policy for each operation your app needs.

Conclusion

Row Level Security is the foundation of data protection in every Supabase application. It shifts authorization from your application code into the database itself, creating a security boundary that cannot be bypassed by API bugs, misconfigured endpoints, or leaked keys.

The steps to get it right are straightforward: enable RLS on every table, write clear policies using auth.uid(), index the columns your policies reference, and test from the client SDK rather than the SQL Editor. For advanced use cases, leverage security definer functions for role checks, structure your subqueries for performance, and keep your service role client completely separate from your browser client.

The real-world breaches we have seen prove that skipping RLS is not a theoretical risk. Make it the first thing you configure for every new table, and your Supabase app will be secure by default.