What you'll learn in this guide:
- What Row-Level Security actually does at the database layer and why it matters for multi-user apps
- How to enable RLS on an existing Supabase table without breaking active queries
- How to write SELECT, INSERT, UPDATE, and DELETE policies from scratch
- How to test that your policies work correctly before going to production
- The three most common RLS patterns and when to use each one > Prerequisite knowledge: Basic familiarity with Supabase, a working project with at least one table, and an understanding of what JWTs are. No advanced PostgreSQL knowledge required.
Why This Matters More for AI-Generated Apps
AI code generators move fast — and that speed has a tradeoff. Tools like Lovable, Bolt.new, and Replit Agent routinely scaffold a Supabase project, create tables, and wire up the client library in minutes. What they frequently skip is enabling Row-Level Security on the tables they create.
That omission is not subtle. By default, Supabase tables are accessible to any authenticated — and sometimes even unauthenticated — user who knows the table name and has the anon key. In a development environment, this is acceptable. In a production environment with real users and real data, it is a critical security gap.
This guide covers the full RLS setup process: enabling it, writing policies, testing them, and applying common patterns that cover the majority of real-world multi-user app requirements. The entire process takes under 30 minutes for a typical application.
Step 1: Enable RLS on Your Table
Enabling RLS is a single toggle or a single SQL statement. It does not delete data, modify schema, or break existing table structure. What it does is change the access model: once RLS is enabled, no query succeeds unless a matching policy explicitly permits it.
Via the Supabase Dashboard:
- Open the Supabase Dashboard and navigate to Table Editor
- Select the table you want to secure
- Click "RLS disabled" in the top-right — it will toggle to "RLS enabled" Via SQL Editor:
ALTER TABLE public.your_table_name ENABLE ROW LEVEL SECURITY;
Replace your_table_name with the actual table name.
Important: Once RLS is enabled, all existing queries from the client library (
supabase.from('your_table').select()) will return zero rows until a policy is added. This is expected behavior — not a bug. Add at least one SELECT policy immediately after enabling.
To confirm RLS is enabled:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';
The rowsecurity column will show true for tables with RLS enabled.
Step 2: Write Your First SELECT Policy
Policies are written in SQL. Each policy specifies:
- Command: SELECT, INSERT, UPDATE, DELETE, or ALL
-
Role:
authenticated,anon, or a custom role -
Using expression: A boolean condition evaluated against each row; only rows where this returns
trueare returned The most common starting policy is "authenticated users can only read their own rows."
-- Allow authenticated users to SELECT only their own rows
CREATE POLICY "Users can view their own data"
ON public.your_table_name
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
What this does:
-
auth.uid()returns the UUID of the currently authenticated user (from the JWT Supabase validates on every request) -
user_idis the column in your table that stores which user a row belongs to - The policy evaluates this condition for every row — only rows where
auth.uid() = user_idistrueare returned If your column is namedowner_id,created_by, or anything else, replaceuser_idaccordingly.
To create this policy via the Dashboard:
- Navigate to Authentication → Policies
- Find your table and click "New Policy"
- Use the template "Enable read access for users based on their user ID"
- Adjust the column name to match your schema
Step 3: Add INSERT, UPDATE, and DELETE Policies
A SELECT policy alone leaves write operations unprotected. Add the remaining three:
-- Allow authenticated users to INSERT rows tied to their own user_id
CREATE POLICY "Users can insert their own data"
ON public.your_table_name
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
Note the difference: INSERT policies use WITH CHECK instead of USING. The WITH CHECK expression validates the data being written; USING filters rows being read.
-- Allow authenticated users to UPDATE only their own rows
CREATE POLICY "Users can update their own data"
ON public.your_table_name
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
UPDATE policies use both. USING determines which rows can be targeted; WITH CHECK validates that the new data being written is still owned by the same user (prevents ownership reassignment).
-- Allow authenticated users to DELETE only their own rows
CREATE POLICY "Users can delete their own data"
ON public.your_table_name
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
Step 4: Test Your Policies Before Going Live
Testing RLS policies requires simulating what different roles will see. Supabase provides two ways to do this.
Method 1: Test via the SQL Editor using SET LOCAL role
-- Simulate what an authenticated user with a specific UUID sees
BEGIN;
SET LOCAL role TO authenticated;
SET LOCAL "request.jwt.claims" TO '{"sub": "your-test-user-uuid-here", "role": "authenticated"}';
SELECT * FROM public.your_table_name;
ROLLBACK;
Replace your-test-user-uuid-here with a UUID that exists in your user_id column. The SELECT should return only that user's rows. If it returns all rows, the policy is not being evaluated correctly.
Method 2: Test via the Supabase client with a real session
// Sign in as a test user
const { data: session } = await supabase.auth.signInWithPassword({
email: 'testuser@example.com',
password: 'testpassword',
});
// Attempt to read data — should return only this user's rows
const { data, error } = await supabase.from('your_table_name').select('*');
console.log(data); // Should only contain rows where user_id = this user's UUID
Run the same query while signed in as a different user and confirm the results are scoped correctly.
What to check:
- A user reading their own rows → returns correct data
- A user reading another user's rows directly → returns empty array, no error
- An unauthenticated request → returns empty array (unless you have an
anonpolicy) - An INSERT with a mismatched
user_id→ returns a policy violation error
Step 5: Three Common RLS Patterns
Most multi-user applications use one or more of these three patterns. Copy, adjust the column names, and apply.
Pattern A: Owner-Based Access (single user owns each row)
The pattern covered in Steps 2–3 above. One row belongs to one user. Used for: user profiles, personal notes, individual settings, private content.
-- Full CRUD for row owner only
CREATE POLICY "Owner full access"
ON public.your_table_name
FOR ALL
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
Using FOR ALL combines SELECT, INSERT, UPDATE, and DELETE into a single policy. Simpler for tables that only ever need owner-level access.
Pattern B: Team/Organization-Based Access
Multiple users share access to rows that belong to a shared entity (an organization, workspace, or team). Requires a join table mapping users to organizations.
-- Users can read rows belonging to any organization they're a member of
CREATE POLICY "Team members can view org data"
ON public.your_table_name
FOR SELECT
TO authenticated
USING (
organization_id IN (
SELECT organization_id
FROM public.organization_members
WHERE user_id = auth.uid()
)
);
This pattern scales cleanly to role-based permissions within a team by adding a role column to organization_members and filtering on it.
Pattern C: Public Read, Authenticated Write
Content that anyone can read (blog posts, public listings, product catalogs) but only authenticated users can create or modify.
-- Anyone can read
CREATE POLICY "Public read access"
ON public.your_table_name
FOR SELECT
TO anon, authenticated
USING (true);
-- Only authenticated users can write their own content
CREATE POLICY "Authenticated users can write"
ON public.your_table_name
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = author_id);
Common Mistakes to Avoid
1. Forgetting WITH CHECK on INSERT and UPDATE
USING applies to rows being read or targeted. WITH CHECK applies to data being written. Omitting WITH CHECK on write operations means the policy validates the target row but not the new data — a user can potentially reassign ownership by changing the user_id field on UPDATE.
2. Using the service role key on the client
The service role key bypasses RLS entirely. It is intended for server-side admin operations only. If the service role key is exposed in client-side code, RLS provides no protection. Use the anon key in browser clients; reserve the service role key for server environments.
3. Enabling RLS without adding a policy first
RLS with no policies means zero rows are accessible to anyone. Test a new policy immediately after enabling RLS on any table with active queries.
4. Not testing with the anon role
Unauthenticated users hit the anon role. If no policy exists for anon, unauthenticated requests return nothing — which is usually correct, but should be a deliberate choice rather than an accidental one.
Further Reading
- Supabase Row Level Security documentation — the canonical reference for policy syntax and edge cases
- PostgreSQL Row Security Policies — the upstream PostgreSQL documentation; useful for advanced policy expressions
- Supabase Auth Deep Dive series — video walkthrough of RLS concepts with live examples
- Platforms that generate Supabase-ready schemas — such as imagine.bo, which scaffolds full-stack apps with database schema included — sometimes output tables without RLS enabled by default; this guide covers the manual hardening steps for those cases.
Frequently Asked Questions
Does enabling RLS affect performance?
Yes, but modestly for most applications. Each policy expression is evaluated as a filter on every query. Simple expressions (auth.uid() = user_id) add negligible overhead. Policies that involve subqueries (the team/organization pattern) can add latency on large tables — indexing the join column (organization_id, user_id on the membership table) mitigates this.
Can I have multiple policies on the same table?
Yes. Supabase evaluates all applicable policies for a given command and role using OR logic — a row is accessible if any one policy permits it. This means adding policies is additive, not restrictive. To restrict access, the policies themselves must contain the limiting logic.
What is the difference between authenticated and anon roles in Supabase?
anon is the role used for requests that do not include a valid JWT — unauthenticated visitors. authenticated is the role for requests that include a valid Supabase session JWT. Most data policies target authenticated; anon policies are used for publicly readable content.
Can RLS be disabled per-query for admin operations?
Yes. Queries executed with the service role key bypass RLS entirely. Queries executed inside a PostgreSQL function with SECURITY DEFINER also bypass RLS unless the function explicitly re-enables it. Both approaches should be used with care and only in server-side code.
Does RLS work with Supabase Realtime subscriptions?
Yes. Realtime respects RLS policies. A user subscribed to a channel will only receive events for rows they have SELECT access to. The policy evaluation happens on the server before events are broadcast to the client.
Top comments (0)