Why RLS Matters for SaaS
Without RLS, your API is the only barrier between a user and every row in your database. One accidental "fetch all" query and you have a data breach.
With RLS, PostgreSQL enforces access rules at query execution time. No matter what your API sends, the database only returns rows the authenticated user is allowed to see. This is security-in-depth, your API and your database both enforce access control independently.
Enabling RLS
Enable RLS on every user-facing table. Tables without RLS policies are wide open by default (if accessed via the service role key) or completely inaccessible (if accessed via the anon key without policies).
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
Once enabled, no rows are returned by default until you create policies. This is the correct security posture.
The Basic User Ownership Policy
The most common pattern, users can only see and modify their own rows: CREATE POLICY "Users can view own rows" ON projects FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own rows" ON projects FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own rows" ON projects FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own rows" ON projects FOR DELETE USING (auth.uid() = user_id);
Multi-Tenant Workspace Policies
For SaaS with teams and workspaces, check workspace membership:
CREATE POLICY "Workspace members can view projects"
ON projects FOR SELECT
USING (
workspace_id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = auth.uid()
)
);
This checks the workspace_members junction table on every query. Index workspace_members(user_id, workspace_id) for performance.
Role-Based Access Control
Add role checking for admin operations:
CREATE POLICY "Only admins can delete workspace projects"
ON projects FOR DELETE
USING (
workspace_id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = auth.uid() AND role = 'admin'
)
);
Store roles in your workspace_members table (role text DEFAULT 'member'). Roles: owner, admin, member, viewer.
Common Mistakes to Avoid
1. Forgetting to enable RLS on new tables. Create a checklist: every new table gets RLS enabled before it's connected to the frontend.
2. Using service role key in the frontend. The service role key bypasses RLS. Never expose it to users, use the anon key in WeWeb/FlutterFlow and the service role only in server-side Xano functions.
3. No policy for INSERT. Many developers add SELECT and UPDATE policies but forget INSERT. Without it, anon key users can't create rows at all.
4. N+1 policy lookups. If your policy JOINs a large table on every query, you'll see performance issues at scale. Materialise membership lookups or use indexes aggressively.
Writing RLS Policies from Scratch
Every RLS policy has three components: the table it applies to, the operation it governs (SELECT, INSERT, UPDATE, DELETE, or ALL), and the expression that evaluates to true or false for each row. The USING clause applies to rows being read or modified, it runs a check against existing rows. The WITH CHECK clause applies only to INSERT and UPDATE, it validates the new row being written.
A common source of confusion: you need separate USING and WITH CHECK expressions for UPDATE policies. USING controls which rows the user can update (which rows they can find), and WITH CHECK controls what values they can write into those rows. If you want users to be able to update their own rows but not change the user_id field, your UPDATE policy needs: USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id). Without the WITH CHECK, a user could update a row they own and set user_id to another user's ID, effectively transferring ownership.
For complex policies involving subqueries (workspace membership checks, permission table lookups), write the subquery out first and test it as a standalone SELECT before embedding it in a policy. PostgreSQL's EXPLAIN ANALYZE is your friend here, run it on a policy expression to see if it uses indexes or falls back to a sequential scan.
Common RLS Patterns
Pattern 1: User owns row. The simplest and most common. user_id column references auth.users. USING (auth.uid() = user_id). Works for notes, documents, personal settings.
Pattern 2: Team or workspace access. A workspace_members junction table determines access. USING (workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = auth.uid())). Works for all shared-resource SaaS.
Pattern 3: Public read, owner write. Content platforms where anyone can read but only the author can edit. SELECT policy: USING (true) or USING (published = true). UPDATE/DELETE policy: USING (auth.uid() = author_id). Works for blogs, directories, marketplaces.
Pattern 4: Admin bypass. Admins can see all rows in a workspace regardless of other ownership rules. Add an OR clause: USING (auth.uid() = user_id OR auth.uid() IN (SELECT user_id FROM workspace_members WHERE role = 'admin' AND workspace_id = projects.workspace_id)). Use this pattern sparingly, complex OR conditions are harder to audit and may have performance implications.
Testing RLS Policies
Testing RLS is one of the most important and most skipped steps in Supabase development. The goal is to verify that users can see exactly the rows they should see, and cannot see rows they should not, using realistic test data that exercises each policy branch.
The fastest way to test RLS in Supabase is the Row Level Security Policies tester in the Supabase dashboard. Go to Authentication → Policies → click 'Test Policy' on any policy. Enter a user's UUID and run a query, you see exactly what that user would receive. Create test users for each role in your system (admin, member, viewer) and verify their access against your test data before deploying.
For automated testing, use Supabase's JavaScript client with explicit user sessions. Create a test user, sign them in, then assert that queries return the expected rows and that writes to unauthorised rows fail. Store these tests alongside your application code and run them in CI. We typically write 5–10 RLS tests per table covering the common cases: own rows visible, other users' rows invisible, correct role required for delete, insert blocked without proper ownership.
RLS Performance Considerations
RLS policies run on every query against the affected table. A simple equality check (auth.uid() = user_id) adds negligible overhead. A subquery (checking workspace_members on every row) can add significant latency at scale if not indexed correctly.
The performance rule is: index every column that appears in a policy subquery WHERE clause. For workspace membership checks, you need an index on workspace_members(user_id) and ideally a composite index on workspace_members(user_id, workspace_id). Without these, PostgreSQL performs a sequential scan of workspace_members for every row returned by your main query, at 10K members this is imperceptible, at 1M it is catastrophic.
A useful technique for reducing policy evaluation overhead on hot tables is security definer functions. Instead of embedding a subquery in every policy, create a PostgreSQL function that returns the list of workspace IDs for the current user, mark it as SECURITY DEFINER and STABLE, and call it from your policies. PostgreSQL can cache the result of STABLE functions within a single query execution, eliminating repeated subquery evaluations. This can reduce policy overhead by 60–80% on join-heavy queries.