Mastering Supabase Auth and Row Level Security for Multi-Tenant SaaS
If you have ever experienced the 3 AM panic of realizing Tenant A might have just accessed Tenant B's data, you know the sheer terror of multi-tenant data leaks. When building SaaS products, application-level security—where your Node.js or Next.js backend checks if a user has access before querying the database—is a fragile illusion. A single missed WHERE clause or a forgotten middleware check can lead to a catastrophic breach.
This is where Supabase Auth combined with PostgreSQL Row Level Security (RLS) becomes your ultimate safety net. By pushing authorization down to the database layer, you ensure that no matter what happens in your application code, the database will never leak unauthorized data.
In this guide, we will dive deep into architecting a secure, high-performance multi-tenant SaaS using Supabase. We will cover robust data modeling, injecting custom claims into JWTs, and writing database-level tests for your RLS policies.
Architecture: How Supabase Auth Talks to Postgres
Before writing code, we must understand the flow. When a user logs in via Supabase Auth, they receive a JSON Web Token (JWT). When your frontend makes a request to the Postgres database (via the Supabase client or PostgREST), this JWT is passed in the Authorization header.
PostgreSQL intercepts this request. Through a custom GUC (Grand Unified Configuration) variable, Postgres exposes the decoded JWT claims to your RLS policies via the auth.jwt() function. Your RLS policies then evaluate these claims against the row data to determine access.
This means your database is entirely self-sufficient for authorization. It does not need to call your application server to verify permissions.
Deep Dive 1: Designing a Bulletproof RLS Data Model
A common mistake in multi-tenant SaaS is tying permissions directly to the user. In reality, users belong to organizations (tenants), and their permissions are scoped to that organization.
Let's design a schema that enforces this relationship strictly.
-- 1. Create the organizations table
CREATE TABLE public.organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 2. Create a mapping table for users and organizations
CREATE TABLE public.org_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES public.organizations(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
UNIQUE(org_id, user_id)
);
-- 3. Create a tenant-scoped resource table
CREATE TABLE public.documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 4. Enable RLS on all tables
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.org_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
Now, let's write the RLS policies. The golden rule of RLS is to always filter by the tenant ID (org_id) and verify the user's membership.
-- Policy for documents: Users can only see documents in their organizations
CREATE POLICY "Users can view documents in their orgs"
ON public.documents
FOR SELECT
USING (
org_id IN (
SELECT org_id FROM public.org_members WHERE user_id = auth.uid()
)
);
-- Policy for documents: Only admins and owners can update
CREATE POLICY "Admins and owners can update documents"
ON public.documents
FOR UPDATE
USING (
org_id IN (
SELECT org_id FROM public.org_members
WHERE user_id = auth.uid() AND role IN ('admin', 'owner')
)
);
-- Policy for org_members: Users can see their own memberships
CREATE POLICY "Users can view their own memberships"
ON public.org_members
FOR SELECT
USING (user_id = auth.uid());
Notice that we didn't just check auth.uid(). We joined against org_members to ensure the user actually belongs to the organization that owns the document. This is the core of multi-tenant isolation.
Deep Dive 2: Injecting Custom Claims via Supabase Auth Hooks
While the subquery approach above works, running a SELECT inside an RLS policy for every single row can become a performance bottleneck at scale. Furthermore, what if you need to enforce role-based access control (RBAC) where the user's role is checked frequently?
Instead of querying the database on every request, we can inject the user's active org_id and role directly into the JWT as custom claims using Supabase Auth Hooks.
Supabase Auth Hooks allow you to intercept the token creation process. We will write a Deno Edge Function to fetch the user's primary organization and inject it into the JWT.
// supabase/functions/auth-hook/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
serve(async (req) => {
const { user, auth_event } = await req.json()
// Initialize admin client to bypass RLS for reading user data
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
let customClaims = {}
if (auth_event.action === 'token_refreshed' || auth_event.action === 'signup') {
// Fetch the user's primary organization and role
const { data: membership } = await supabaseAdmin
.from('org_members')
.select('org_id, role')
.eq('user_id', user.id)
.limit(1)
.single()
if (membership) {
customClaims = {
org_id: membership.org_id,
role: membership.role
}
}
}
return {
claims: customClaims
}
})
Once this hook is deployed and configured in your Supabase dashboard, every JWT issued to the user will contain org_id and role. Your RLS policies become incredibly simple and blazing fast:
CREATE POLICY "Fast document view policy"
ON public.documents
FOR SELECT
USING (org_id = (auth.jwt() ->> 'org_id')::UUID);
By moving the tenant resolution to the JWT, we eliminate database joins inside the RLS policy, drastically reducing query execution time.
Deep Dive 3: Testing RLS Policies Without the API
One of the biggest pitfalls developers face with RLS is assuming it works because the API returns the correct data. But what if a developer writes a raw SQL query in a migration script? What if a new policy inadvertently opens up data?
You must test RLS at the database level. Fortunately, Postgres allows us to mock the auth.jwt() context using set_config.
Here is a robust testing strategy using raw SQL. You can run this in your Supabase SQL Editor or integrate it into your CI/CD pipeline.
-- 1. Create a test user in auth.users (if not using a test framework that handles this)
-- For this example, assume we have a user with ID '11111111-1111-1111-1111-111111111111'
-- 2. Mock the JWT for User A (Org 1, Admin)
SELECT set_config('request.jwt.claims', '{"sub": "11111111-1111-1111-1111-111111111111", "org_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "role": "admin"}', true);
-- 3. Attempt to select documents
-- This should return documents for Org A, but NOT Org B
SELECT id, org_id, title FROM public.documents;
-- 4. Assert the results (in a real test runner, you would check the row count)
-- If this returns rows with org_id != 'aaaaaaaa...', your RLS is broken.
-- 5. Now, switch context to User B (Org 2, Member)
SELECT set_config('request.jwt.claims', '{"sub": "22222222-2222-2222-2222-222222222222", "org_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "role": "member"}', true);
-- 6. Attempt an UPDATE on Org A's document
-- This should fail with a policy violation or return 0 rows updated
UPDATE public.documents SET title = 'Hacked' WHERE org_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
-- 7. Reset the context after testing
SELECT set_config('request.jwt.claims', '', true);
This pattern allows you to write comprehensive integration tests for your security policies without needing to spin up the entire Supabase stack or mock HTTP requests. It tests the exact boundary where data leaks actually happen.
Performance Considerations and Common Pitfalls
While RLS is powerful, it introduces specific performance characteristics that mid-to-senior developers must navigate.
1. The Indexing Trap
RLS policies are essentially WHERE clauses appended to every query. If your policy filters by org_id, you must have an index on org_id. Without it, Postgres will perform a sequential scan on every table, bringing your database to its knees as data grows.
-- Always index your RLS filter columns!
CREATE INDEX idx_documents_org_id ON public.documents(org_id);
CREATE INDEX idx_org_members_user_id ON public.org_members(user_id);
2. The security definer Bypass
If you create a Postgres function with SECURITY DEFINER, it executes with the privileges of the user who created the function (usually postgres), completely bypassing RLS. If you must use SECURITY DEFINER (e.g., for a function that inserts into auth.users), ensure you explicitly set the search_path and manually re-apply RLS checks inside the function, or restrict its usage strictly.
3. JWT Bloat and Caching
If you inject too many custom claims into the JWT, the token size grows. Since the JWT is sent in the header of every request, bloated tokens increase latency and bandwidth. Furthermore, remember that JWTs are cached. If a user's role changes from 'member' to 'admin', the change won't reflect until their token expires or is refreshed. Always implement a mechanism to force token refresh when critical permissions change.
Key Takeaways
- Never trust application-level security for multi-tenant data. Push authorization down to the database using RLS.
-
Design your schema around tenants. Use mapping tables (
org_members) to link users to organizations, rather than assigning resources directly to users. - Optimize RLS with Custom Claims. Use Supabase Auth Hooks to inject tenant IDs and roles into the JWT, avoiding expensive joins inside your RLS policies.
-
Test at the database layer. Use
set_configto mock JWTs and write SQL-based tests for your RLS policies. API tests are not enough. - Index your filter columns. RLS policies without corresponding database indexes will destroy your query performance.
When I was architecting the multi-tenant isolation for PubliFlow (publiflow.vip), a Next.js 15 SaaS starter kit I recently built, I found that relying solely on API-level tests was a massive blind spot. Implementing these exact SQL-based RLS testing patterns at the database level saved us from a critical data leak during load testing, proving that database-enforced security is the only true safety net. Building a SaaS is hard enough; let PostgreSQL do the heavy lifting for your security.
Top comments (0)