Secure your data with PostgreSQL Row Level Security policies
Row Level Security (RLS) is PostgreSQL’s most powerful security feature, providing granular control over which rows users can access in your database. When properly configured, RLS ensures users can only see and modify data they’re authorized to access.
RLS is a PostgreSQL feature that adds an implicit WHERE clause to every query based on policies you define. This means security is enforced at the database level, protecting your data even if accessed through third-party tools.
RLS works with Supabase Auth to provide end-to-end security from the browser to the database.
Tables in the public schema without RLS enabled are accessible to anyone with your anon key.Supabase allows browser access to your database for convenience, but this requires RLS to be secure.
RLS provides:
Defense in depth: Protection even if application code has vulnerabilities
Automatic enforcement: No way to bypass security from client code
Granular control: Different rules for SELECT, INSERT, UPDATE, DELETE
Third-party protection: Security maintained when using external tools
Create an event trigger to automatically enable RLS:
CREATE OR REPLACE FUNCTION rls_auto_enable()RETURNS EVENT_TRIGGERLANGUAGE plpgsqlSECURITY DEFINERSET search_path = pg_catalogAS $$DECLARE cmd record;BEGIN FOR cmd IN SELECT * FROM pg_event_trigger_ddl_commands() WHERE command_tag IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') AND object_type IN ('table','partitioned table') LOOP IF cmd.schema_name = 'public' THEN BEGIN EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', cmd.object_identity); RAISE LOG 'Auto-enabled RLS on %', cmd.object_identity; EXCEPTION WHEN OTHERS THEN RAISE LOG 'Failed to enable RLS on %', cmd.object_identity; END; END IF; END LOOP;END;$$;CREATE EVENT TRIGGER ensure_rls ON ddl_command_end WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') EXECUTE FUNCTION rls_auto_enable();
This trigger only affects tables created after installation. Enable RLS manually on existing tables.
-- Everyone can view published postsCREATE POLICY "Public posts are viewable" ON posts FOR SELECT TO anon USING (published = true);-- Users can view their own postsCREATE POLICY "Users can view own posts" ON posts FOR SELECT TO authenticated USING (auth.uid() = user_id);
-- Users can update their own postsCREATE POLICY "Users can update own posts" ON posts FOR UPDATE TO authenticated USING (auth.uid() = user_id) -- Must own the row WITH CHECK (auth.uid() = user_id); -- Can't change ownership
UPDATE operations require a matching SELECT policy to work properly.
-- Table schemaCREATE TABLE profiles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES auth.users NOT NULL, username TEXT, avatar_url TEXT);ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;-- Everyone can view profilesCREATE POLICY "Profiles are publicly viewable" ON profiles FOR SELECT TO anon, authenticated USING (true);-- Users can update their own profileCREATE POLICY "Users can update own profile" ON profiles FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Users can access data from their teamCREATE POLICY "Team members can view team data" ON documents FOR SELECT TO authenticated USING ( team_id IN ( SELECT team_id FROM team_members WHERE user_id = auth.uid() ) );
-- Store roles in app_metadataCREATE POLICY "Admins can view all data" ON sensitive_data FOR SELECT TO authenticated USING ( (auth.jwt() ->> 'app_metadata')::jsonb ->> 'role' = 'admin' );
-- Require MFA for sensitive operationsCREATE POLICY "MFA required for updates" ON sensitive_table AS RESTRICTIVE FOR UPDATE TO authenticated USING ( (auth.jwt() ->> 'aal') = 'aal2' );
-- Check user role from app_metadataCREATE POLICY "Admin access" ON admin_panel FOR ALL TO authenticated USING ( (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );-- Check team membershipCREATE POLICY "Team access" ON team_data FOR SELECT TO authenticated USING ( team_id = ANY( SELECT jsonb_array_elements_text( (auth.jwt() -> 'app_metadata' -> 'teams')::jsonb )::uuid ) );
-- Policy uses user_idCREATE POLICY "User data" ON posts USING (user_id = auth.uid());-- Add index on user_idCREATE INDEX idx_posts_user_id ON posts(user_id);
Even with policies, always filter in your queries:
// Don't do thisconst { data } = await supabase .from('posts') .select('*')// Do this insteadconst { data } = await supabase .from('posts') .select('*') .eq('user_id', userId)
-- Create function that runs as creator (bypasses RLS)CREATE FUNCTION private.get_user_teams()RETURNS TABLE(team_id UUID)LANGUAGE sqlSECURITY DEFINERAS $$ SELECT team_id FROM team_members WHERE user_id = auth.uid()$$;-- Use in policyCREATE POLICY "Team access" ON documents USING ( team_id IN (SELECT private.get_user_teams()) );
Never put security definer functions in schemas exposed via PostgREST (like public). Use a private schema.
-- Before: Slow (joins tables)CREATE POLICY "Team access" USING ( auth.uid() IN ( SELECT user_id FROM team_members WHERE team_members.team_id = documents.team_id ) );-- After: Fast (no join)CREATE POLICY "Team access" USING ( team_id IN ( SELECT team_id FROM team_members WHERE user_id = auth.uid() ) );
-- Before: Runs for all rolesCREATE POLICY "User access" USING (auth.uid() = user_id);-- After: Only runs for authenticatedCREATE POLICY "User access" TO authenticated USING ((SELECT auth.uid()) = user_id);
Impact: 99.78% faster for anon users (policy skipped entirely)
-- Test as anonymous userSET ROLE anon;SELECT * FROM posts; -- Should only show published posts-- Test as authenticated userSET ROLE authenticated;SET request.jwt.claims.sub = '<user-uuid>';SELECT * FROM posts; -- Should show user's posts-- ResetRESET ROLE;
BEGIN;SELECT plan(3);-- Test anonymous accessSET ROLE anon;SELECT results_eq( 'SELECT id FROM posts WHERE published = false', ARRAY[]::bigint[], 'Anon users cannot see unpublished posts');-- Test authenticated accessSET ROLE authenticated;SET request.jwt.claims.sub = 'user-123';SELECT ok( EXISTS(SELECT 1 FROM posts WHERE user_id = 'user-123'), 'Users can see their own posts');SELECT * FROM finish();ROLLBACK;
Cause: RLS enabled but no policies createdSolution:
-- Check if RLS is enabledSELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public';-- Create appropriate policiesCREATE POLICY "Enable access" ON table_name FOR SELECT TO authenticated USING (true);
Policies not working as expected
Cause: Multiple policies are combined with OR logicSolution:
Check all policies on the table
Use RESTRICTIVE policies for AND logic
Test with specific roles: SET ROLE authenticated
Performance issues
Cause: Missing indexes or inefficient policiesSolution:
Add indexes on columns used in policies
Wrap functions with SELECT
Minimize table joins
Use security definer functions
Always add filters to queries
auth.uid() returns null
Cause: User not authenticated or token expiredSolution:
-- Always check for nullCREATE POLICY "Safe policy" USING ( auth.uid() IS NOT NULL AND auth.uid() = user_id );
Begin with policies that deny everything, then add access:
-- Start hereCREATE POLICY "Deny all" ON table_name USING (false);-- Add specific accessCREATE POLICY "Allow user access" ON table_name FOR SELECT TO authenticated USING (user_id = auth.uid());
3
Test policies thoroughly
Test as different user types before deploying
4
Monitor performance
Use Performance Advisor to find slow policies
5
Document complex policies
Add comments explaining business logic
-- Team admins can delete any team document-- Regular members can only delete their ownCREATE POLICY "Delete permissions" ...