If you have ever built an application with Supabase, you probably know that it lets you query your database directly from your frontend code. You don't need to write a traditional backend API server (like Express or Django) just to fetch data.
While this is incredibly convenient, it raises an obvious and critical security question: If the Supabase URL and client keys are exposed on the frontend, what stops a malicious user from reading or modifying anyone's data?
The answer is Row Level Security (RLS).
In this guide, we'll demystify RLS in Supabase, look at how it works under the hood, and walk through a complete, hands-on example to secure a database table.
What is Row Level Security (RLS)?
Row Level Security is a built-in feature of PostgreSQL (the relational database engine powering Supabase).
In a traditional database, security permissions are typically table-level: a database user either has permission to read the entire table or they don't.
RLS shifts the security paradigm. Instead of checking if a user has access to a table, RLS evaluates policies on a row-by-row basis. When RLS is enabled, every query (SELECT, INSERT, UPDATE, or DELETE) must pass a set of security rules (called Policies) before PostgreSQL returns or modifies the row.
In Supabase, whenever a frontend client performs a query, Supabase passes the user's JWT (JSON Web Token) securely to Postgres. The database then automatically validates that user against your RLS policies.
Why is RLS Mandatory in Supabase?
By default, when you create a new table in Supabase, RLS is disabled or is strongly prompted to be enabled.
- RLS Disabled: Anyone with your publishable
anonkey can read, insert, update, or delete any row in your table. This is a massive security hazard for production apps. - RLS Enabled (without policies): All access is denied. Nobody can read or write any rows.
- RLS Enabled (with policies): Access is granted only if the request satisfies a specific policy.
Therefore, for any table containing user data, your workflow should always be: Enable RLS > Create security policies.
A Simple Example: Securing a todos Table
Let's look at a concrete example. Imagine you are building a Todo application where: 1. Anyone (even unauthenticated guests) can view todos that are marked as public. 2. Logged-in users can view their own private todos. 3. Logged-in users can create, edit, or delete their own todos.
Step 1: Create the Table
First, we create our todos table in Postgres:
create table todos (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users not null,
task text not null,
is_complete boolean default false,
is_public boolean default false,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
Step 2: Enable Row Level Security
Next, we tell PostgreSQL to turn on Row Level Security for our new table:
alter table todos enable row level security;
At this exact moment, if you try to query
supabase.from('todos').select('*')from your frontend, you will receive an empty array[]. Since RLS is enabled and we haven't created any policies yet, Postgres blocks all read and write attempts.
Step 3: Write the Security Policies
Polices are SQL rules that return a boolean value (true or false). If a policy returns true for a given row and action, access is granted.
Supabase provides powerful SQL helper functions to inspect the incoming request:
* auth.uid(): Extracts the unique ID (UUID) of the authenticated user making the request. If the user is not logged in, it returns null.
* auth.role(): Extracts the role of the user (typically authenticated or anon).
Let's write our three policies.
Policy A: Allow anyone to view public todos
We want anyone (logged in or not) to be able to read rows where is_public is true.
create policy "Allow public read access"
on todos
for select
using (is_public = true);
on todos: Applies to thetodostable.for select: Applies only to read queries.using (is_public = true): The row is visible only if itsis_publiccolumn evaluates totrue.
Policy B: Allow authenticated users to view their own todos
We want logged-in users to read their own todos, even if they are private (is_public = false).
create policy "Allow individual read access"
on todos
for select
using (auth.uid() = user_id);
auth.uid() = user_id: Compares the logged-in user's ID with theuser_idcolumn of the row. If they match, the row is returned.
Policy C: Allow users to insert their own todos
We want logged-in users to insert new todos, but we must guarantee they set the user_id to their own ID so they can't impersonate someone else.
create policy "Allow individual insert access"
on todos
for insert
with check (auth.uid() = user_id);
for insert: Applies only to insertion queries.with check (auth.uid() = user_id): Validates that the data being inserted matches the user's actual ID. If a user tries to submit an insert request with someone else'suser_id, Postgres rejects the entire query.
Policy D: Allow users to update and delete their own todos
We want users to modify or delete only the rows that belong to them.
create policy "Allow individual update and delete access"
on todos
for all
using (auth.uid() = user_id);
for all: Applies to SELECT, INSERT, UPDATE, and DELETE. However, since we already have specific policies for select and insert, this will safely cover update and delete operations.