ignitionstack.pro v1.0 is out! Read the announcement →
Skip to Content

Row Level Security (RLS)

Row Level Security (RLS) is a PostgreSQL feature that allows controlling access to individual table rows based on the authenticated user.

Why RLS?

Fundamental Concepts

1. Policies

Define who can do what with which rows.

CREATE POLICY policy_name ON table FOR operation -- SELECT, INSERT, UPDATE, DELETE, ALL TO role -- public, authenticated, anon USING (condition) -- When row can be accessed (SELECT, UPDATE, DELETE) WITH CHECK (condition) -- When row can be inserted/updated (INSERT, UPDATE)

2. Default Supabase Roles

3. Supabase Helpers

Policy Patterns

1. Public Read, Private Write

Scenario: Public blog posts, but only author can edit/delete

-- Enable RLS ALTER TABLE posts ENABLE ROW LEVEL SECURITY; -- Anyone can read published posts CREATE POLICY "Published posts are public" ON posts FOR SELECT TO public USING (published = true); -- Only authenticated can create posts CREATE POLICY "Authenticated users can create posts" ON posts FOR INSERT TO authenticated WITH CHECK (auth.uid() = author_id); -- Only author can update their posts CREATE POLICY "Authors can update their posts" ON posts FOR UPDATE TO authenticated USING (auth.uid() = author_id) WITH CHECK (auth.uid() = author_id); -- Only author can delete their posts CREATE POLICY "Authors can delete their posts" ON posts FOR DELETE TO authenticated USING (auth.uid() = author_id);

2. Fully Private Data

Scenario: User table - each sees only their data

ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY; -- User sees only their profile CREATE POLICY "Users see only their profile" ON user_profiles FOR SELECT TO authenticated USING (auth.uid() = user_id); -- User can only update their profile CREATE POLICY "Users update only their profile" ON user_profiles FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);

3. Role/Permission Based Access

Scenario: Admin can do everything

-- Add role column to users table ALTER TABLE user_profiles ADD COLUMN role TEXT DEFAULT 'user'; -- Create helper function CREATE OR REPLACE FUNCTION is_admin() RETURNS BOOLEAN AS $$ SELECT EXISTS ( SELECT 1 FROM user_profiles WHERE user_id = auth.uid() AND role = 'admin' ); $$ LANGUAGE SQL SECURITY DEFINER; -- Policy for admins CREATE POLICY "Admins can do everything" ON posts FOR ALL TO authenticated USING (is_admin()) WITH CHECK (is_admin());

4. Organization/Workspace Access

Scenario: Multi-tenancy - users see only their organization’s data

ALTER TABLE documents ENABLE ROW LEVEL SECURITY; -- Organization members table CREATE TABLE organization_members ( org_id UUID REFERENCES organizations(id), user_id UUID REFERENCES auth.users(id), role TEXT DEFAULT 'member', PRIMARY KEY (org_id, user_id) ); -- Helper function CREATE OR REPLACE FUNCTION user_orgs() RETURNS SETOF UUID AS $$ SELECT org_id FROM organization_members WHERE user_id = auth.uid(); $$ LANGUAGE SQL SECURITY DEFINER; -- Policy: user accesses their org's documents CREATE POLICY "Users access org documents" ON documents FOR SELECT TO authenticated USING (org_id IN (SELECT user_orgs()));

Common Pitfalls

1. Forgetting to Enable RLS

-- NEVER do this in production -- Without RLS = no protection! -- ALWAYS enable RLS ALTER TABLE table ENABLE ROW LEVEL SECURITY;

2. Using TO public Incorrectly

-- WRONG: Allows unauthenticated access to everything CREATE POLICY "bad_policy" ON sensitive_table FOR ALL TO public USING (true); -- CORRECT: Specify conditions CREATE POLICY "good_policy" ON posts FOR SELECT TO public USING (published = true);

3. Confusing USING and WITH CHECK

-- USING: Determines which rows can be READ/MODIFIED -- WITH CHECK: Determines which values can be INSERTED/UPDATED -- WRONG: Allows user to change author_id on update CREATE POLICY "bad_update" ON posts FOR UPDATE TO authenticated USING (auth.uid() = author_id); -- Missing WITH CHECK! -- CORRECT CREATE POLICY "good_update" ON posts FOR UPDATE TO authenticated USING (auth.uid() = author_id) WITH CHECK (auth.uid() = author_id); -- Ensures author_id doesn't change

Testing Policies

Via SQL Editor

-- Test as authenticated user SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claims" TO '{"sub": "user-uuid-here"}'; SELECT * FROM posts; -- Should return only user's posts -- Reset RESET ROLE;

Via TypeScript

import { createClient } from '@/lib/supabase/server' async function testRLS() { const supabase = await createClient() // Try to access another user's posts const { data, error } = await supabase .from('posts') .select('*') .eq('author_id', 'another-user-id') if (data && data.length === 0) { console.log('RLS working: did not return other users posts') } }

Security Best Practices

1. Always Enable RLS

-- On ALL sensitive tables ALTER TABLE table ENABLE ROW LEVEL SECURITY;

2. Use Service Role Only Server-Side

// NEVER do this client-side const supabase = createClient(url, SERVICE_ROLE_KEY) // Use only in Server Actions/Components import { createAdminClient } from '@/lib/supabase/server'

3. Validate Data with Zod

import { z } from 'zod' const PostSchema = z.object({ title: z.string().min(5).max(200), content: z.string().min(10), published: z.boolean().default(false), }) export async function createPost(input: unknown) { const validated = PostSchema.parse(input) // Proceed with insert... }

Security Checklist