Supabase’s Row Level Security (RLS) is often misunderstood as just a simple WHERE clause on steroids, but its real power lies in its ability to make decisions based on the identity of the user making the request, not just the data itself.

Let’s see RLS in action. Imagine a simple todos table.

CREATE TABLE todos (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id uuid NOT NULL,
    task text NOT NULL,
    is_complete boolean DEFAULT false,
    created_at timestamp with time zone DEFAULT timezone('utc'::text, now())
);

Now, let’s enable RLS and define a policy. We want users to only see their own todos.

ALTER TABLE todos ENABLE ROW LEVEL SECURITY;

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

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

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

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

When a user logs in, Supabase issues a JWT containing their sub (subject) claim, which is their user ID. When a query hits the database, the auth.uid() function inside the RLS policy dynamically retrieves this ID from the JWT. If auth.uid() matches the user_id column in a row, that row is included in the result set or allowed to be modified.

Consider this scenario:

  1. User A logs in. Their JWT contains {"sub": "a1b2c3d4-e5f6-7890-1234-567890abcdef"}.
  2. User A queries SELECT * FROM todos;
  3. RLS policy Users can view their own todos is evaluated.
  4. auth.uid() returns "a1b2c3d4-e5f6-7890-1234-567890abcdef".
  5. The USING clause becomes ("a1b2c3d4-e5f6-7890-1234-567890abcdef" = user_id).
  6. Only rows where user_id matches User A’s ID are returned.

This mechanism prevents User B (with a different sub in their JWT) from ever seeing User A’s todos, even if they try to query the table directly or through an API that doesn’t re-apply RLS.

The mental model is that RLS isn’t just filtering data after it’s retrieved; it’s fundamentally altering the query before it hits the data. The database itself enforces these rules, making it incredibly secure. You can define different policies for different operations (SELECT, INSERT, UPDATE, DELETE) and even for different roles, allowing for complex access control logic.

A common pattern is to have a profiles table linked to the auth.users table. You’ll often want to ensure a user can only edit their own profile. This is typically done by joining the auth.users table with your profiles table within the RLS policy. For example, on a profiles table with a user_id column that corresponds to auth.users.id, a policy might look like:

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

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

This seems identical to the todos example, but the key is that auth.uid() is always compared against the user_id column in the profiles table itself. The database doesn’t need to know about auth.users directly for this specific policy to work.

The most surprising thing most people don’t realize is that RLS policies are evaluated per statement. This means if a single SELECT statement returns 100 rows, and your policy allows 50 of them, the database actually evaluates the policy for each of those 100 rows. While this is usually highly optimized, it’s why complex joins or subqueries within RLS policies can sometimes become performance bottlenecks, and why ensuring your user_id (or whatever identifier you’re filtering on) is indexed is crucial.

Once you have RLS policies defined, the next hurdle is managing how your application code interacts with them, especially when dealing with administrative tasks or bulk operations.

Want structured learning?

Take the full Supabase course →