Supabase Auth Deep Dive: OAuth, SSO, and Permission Control
That afternoon, a sales colleague rushed over and said, “The client requires Okta login, SSO needs to go live within two weeks.” I was momentarily stunned—my application only supported email registration and Google OAuth. SAML? Never touched it.
Honestly, this scenario is all too common in B2B SaaS. Enterprise clients won’t accept having their employees register separate accounts. They have unified identity management systems (Okta, Azure AD, Google Workspace), login requires a single click to redirect, and when an employee leaves, their account is automatically deactivated.
This article is for exactly this scenario. We’ll start with OAuth social login, transition to SAML SSO enterprise integration, and finally use Row Level Security (RLS) to implement multi-tenant permission isolation—a complete authentication and authorization solution that covers everything from consumer-level to enterprise-grade.
I. OAuth Multi-Provider Configuration in Practice
OAuth social login is the starting point for most applications. Users don’t want to remember passwords, and you don’t want to handle password storage and verification—let Google or GitHub handle it, and both sides are happy.
Supabase supports many OAuth providers: Google, GitHub, Apple, Facebook, Discord, Twitter… But in production environments, Google and GitHub are most widely used, while Apple is a hard requirement for iOS apps (App Store review requires it). Let’s start with Google.
Google OAuth Configuration
Open Google Cloud Console, and those dense menus can be overwhelming at times. Don’t panic—just search for “OAuth Client ID” to find the entry.
When creating, select Web application type. The key step is filling in the Authorized redirect URI:
https://<your-project-ref>.supabase.co/auth/v1/callback
This is where Supabase Auth service receives OAuth callbacks. You can find your project ref in the Supabase Dashboard top-left corner, formatted like abcdefghijklmnop.
After getting the Client ID and Client Secret, return to Supabase Dashboard, find Authentication > Providers, enable Google, and fill in these two values.
The code call is straightforward:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://abcdefghijklmnop.supabase.co',
'your-anon-key'
)
// Initiate OAuth login
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://your-app.com/auth/callback',
scopes: 'email profile'
}
})
if (error) {
console.error('Login failed:', error.message)
return
}
// data.url is the redirect URL, just use window.location.href = data.url
redirectTo is your application’s address for receiving login results. Supabase will pass code and state parameters there. You need to call supabase.auth.exchangeCodeForSession() on that page to complete the login:
// On the /auth/callback page
const { error } = await supabase.auth.exchangeCodeForSession()
if (!error) {
// Login successful, redirect to homepage
window.location.href = '/'
}
GitHub OAuth Configuration
GitHub configuration is similar. The entry is Settings > Developer settings > OAuth Apps. The Authorization callback URL is similarly https://<project-ref>.supabase.co/auth/v1/callback.
One difference: GitHub OAuth Apps don’t have a scopes configuration interface; scopes are specified in code:
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: 'https://your-app.com/auth/callback',
scopes: 'repo user' // repo can access private repositories
}
})
If you’re just doing user login, default scopes are sufficient. If you need to access users’ GitHub data (like syncing repository lists), you’ll need to add repo scope.
Apple OAuth Configuration
Apple’s configuration is the most troublesome. You need to create a Services ID in Apple Developer Portal and generate a private key (.p8 file). The private key can only be downloaded once—if you lose it, you have to regenerate.
Key parameters:
- Services ID: Similar to Client ID
- Team ID: Find in Membership page
- Key ID: The private key’s ID
- Private Key: The downloaded .p8 file content
When filling these values in Supabase Dashboard, copy the entire Private Key file content (including the BEGIN/END lines).
The code call is the same as Google/GitHub:
await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo: 'https://your-app.com/auth/callback'
}
})
For iOS apps, there’s another option: directly call the native Sign in with Apple API, get the identity token, and pass it to Supabase:
// Frontend receives identity_token returned by Apple
const { data, error } = await supabase.auth.signInWithIdTokenCredentials({
provider: 'apple',
token: identityToken
})
This approach is suitable for iOS apps that have already integrated native Apple login, allowing reuse of existing logic.
II. SAML SSO Enterprise Integration
Let’s return to that opening scenario: the client requires Okta login. OAuth isn’t enough here—you need SAML 2.0 SSO.
SAML works completely differently from OAuth. OAuth is users authorizing third-party apps to access their data, while SAML is enterprise Identity Providers (IdP) sending user identity information to your application (ServiceProvider, SP). For enterprises, SAML is more controllable—when a user leaves, the IdP disables the account, and all connected apps automatically become invalid.
Pre-Configuration Preparation
You need to get the IdP’s Metadata from the client. This file contains the IdP’s certificate, endpoint addresses, and other information. Okta, Azure AD, and Google Workspace all have entries for exporting Metadata.
Key URLs provided by Supabase:
EntityID (SP identifier):
https://<project-ref>.supabase.co/auth/v1/sso/saml/metadata
ACS URL (address for receiving SAML Response):
https://<project-ref>.supabase.co/auth/v1/sso/saml/acs
Metadata URL (downloadable):
https://<project-ref>.supabase.co/auth/v1/sso/saml/metadata?download=true
Share these URLs with the client’s IT administrator so they can create a SAML app in Okta/Azure AD.
Using Supabase CLI to Configure SSO
Supabase Dashboard now supports SAML configuration, but I prefer using CLI—after all, enterprise client configurations may need repeated debugging, and command line is more controllable.
# Add SAML connection
supabase sso add --type saml --project-ref <project-ref> \
--metadata-url 'https://company.okta.com/app/exk123/saml/samlmetadata' \
--domains company.com
The --domains parameter is important. It tells Supabase: all users with company.com email addresses should go through this SAML connection when logging in.
You can also use --metadata-file instead of --metadata-url to upload an XML file directly:
supabase sso add --type saml --project-ref <project-ref> \
--metadata-file ./okta-metadata.xml \
--domains company.com
After executing the command, it returns an sso_provider_id, like abc123def456. This ID is key for RLS permission isolation.
Attribute Mapping Configuration
SAML Response contains user information (email, name, department, etc.), but field naming may not be standardized. You need to tell Supabase how to map these fields.
Create a JSON file:
{
"keys": {
"email": {
"name": "email",
"names": ["EmailAddress", "email", "mail"],
"required": true
},
"first_name": {
"name": "first_name",
"names": ["FirstName", "givenName", "first_name"]
},
"last_name": {
"name": "last_name",
"names": ["LastName", "surname", "last_name"]
}
}
}
Then use supabase sso update to apply this configuration:
supabase sso update <sso_provider_id> \
--project-ref <project-ref> \
--attribute-mapping-file ./mapping.json
Multi-Tenant SSO Configuration
A project can have multiple SAML connections. For example, if you have two enterprise clients, Acme Corp (using Okta) and Globex Inc (using Azure AD):
# Add Acme Corp's SSO
supabase sso add --type saml --project-ref <project-ref> \
--metadata-url 'https://acme.okta.com/.../metadata' \
--domains acme.com
# Returns sso_provider_id: provider_abc
# Add Globex Inc's SSO
supabase sso add --type saml --project-ref <project-ref> \
--metadata-url 'https://globex.azure.com/.../metadata' \
--domains globex.com
# Returns sso_provider_id: provider_def
Now Acme Corp employees will go through provider_abc when logging in, and Globex Inc employees go through provider_def. Each SSO connection’s user data is isolated.
User Login Experience
After configuration, the user’s login flow looks like this:
- User enters email (e.g.,
[email protected]) - Supabase detects
acme.comdomain has SSO configuration - Automatically redirects to Acme’s Okta login page
- User enters username and password in Okta (may already be logged in, passes directly)
- Okta sends SAML Response to Supabase
- Supabase verifies and creates session, redirects back to your application
Code to initiate SSO login:
// After user enters email, call this method
const { data, error } = await supabase.auth.signInWithSSO({
domain: 'acme.com'
})
if (data?.url) {
// Redirect to SSO login page
window.location.href = data.url
}
You can also use sso_provider_id directly:
const { data, error } = await supabase.auth.signInWithSSO({
providerId: 'provider_abc'
})
Characteristics of SSO Users
Users created via SSO login are different from regular users:
- Email managed by IdP: Users cannot change email in the application
- No password: Password is stored at IdP, Supabase doesn’t participate in verification
- Email not verifiable: Because IdP has already verified
This means if a user needs to change their email, the client’s IT administrator must do it in Okta/Azure AD.
III. Advanced Row Level Security Usage
Authentication solves the “who is the user” problem, authorization solves the “what can the user do” problem.
The traditional approach is checking permissions in business code: every API needs to determine whether the current user can access that data. Code is duplicated, easily overlooked, and performance is poor—every request needs to query the database to determine permissions.
PostgreSQL’s Row Level Security (RLS) pushes permission checking down to the database layer. Every query automatically carries permission filtering, no need for business code to worry about it.
Default Behavior After Enabling RLS
Many people get stuck on this point: after enabling RLS, all access is denied by default.
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Now any query returns empty (including administrators)
SELECT * FROM posts; -- Returns 0 rows
You must create a Policy to allow access.
Two Key Parts of Policy
A Policy has two clauses: USING and WITH CHECK.
USING clause: Determines whether a user can “see” or “locate” that data. Used for SELECT, UPDATE, DELETE operations—users must first be able to see data before they can modify or delete it.
WITH CHECK clause: Determines whether a user can “write” that data. Used for INSERT, UPDATE operations—data after writing must satisfy the condition.
-- Users can only operate on their own posts
CREATE POLICY "Users manage own posts" ON posts
FOR ALL TO authenticated
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);
-- Only allow viewing (not modifying)
CREATE POLICY "Users view own posts" ON posts
FOR SELECT TO authenticated
USING (auth.uid() = author_id);
-- Only allow inserting (cannot view others')
CREATE POLICY "Users insert posts" ON posts
FOR INSERT TO authenticated
WITH CHECK (auth.uid() = author_id);
auth.uid() returns the current logged-in user’s UUID. Returns null when not logged in, so TO authenticated ensures the Policy only applies to logged-in users.
RESTRICTIVE vs PERMISSIVE
A table can have multiple Policies. The default is PERMISSIVE mode—if any Policy is satisfied, access is allowed.
-- Policy 1: Users can see their own
CREATE POLICY "Own data" ON posts
FOR SELECT USING (auth.uid() = author_id);
-- Policy 2: Public posts visible to everyone
CREATE POLICY "Public posts" ON posts
FOR SELECT USING (is_public = true);
-- Two PERMISSIVE Policies: satisfy either one
RESTRICTIVE Policy means “must satisfy”. It combines with PERMISSIVE Policy to form stricter restrictions.
-- All access must satisfy this condition (as a "global filter")
CREATE POLICY "Tenant isolation" ON posts
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
SELECT tenant_id FROM users WHERE id = auth.uid()
));
-- Then add PERMISSIVE Policy to control specific operations
CREATE POLICY "Authors edit own posts" ON posts
FOR UPDATE USING (auth.uid() = author_id);
The effect of this combination: users must belong to the correct tenant (RESTRICTIVE), and be the author to edit (PERMISSIVE).
Complete Multi-Tenant Isolation Pattern
Suppose you have a SaaS application where each enterprise client is a tenant. The users table has tenant_id, and all business tables need to be isolated by tenant.
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
email TEXT,
sso_provider_id TEXT -- Only SSO users have this
);
-- Business table
CREATE TABLE projects (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
name TEXT,
created_by UUID REFERENCES users(id)
);
-- Enable RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Global tenant isolation (RESTRICTIVE)
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
SELECT tenant_id FROM users WHERE id = auth.uid()
));
-- Specific operation permissions (PERMISSIVE)
CREATE POLICY "Tenant users can view" ON projects
FOR SELECT TO authenticated
USING (true); -- Already filtered by RESTRICTIVE, just allow here
CREATE POLICY "Project creators can edit" ON projects
FOR UPDATE TO authenticated
USING (created_by = auth.uid());
After this configuration, every query automatically carries tenant filtering. Even if business code writes SELECT * FROM projects, the database only returns the current tenant’s data.
Multi-Tenant Isolation for SSO Users
SSO logged-in users can be isolated using sso_provider_id. The JWT has a field that stores the login method:
-- RLS Policy for SSO users
CREATE POLICY "SSO tenant isolation" ON organization_settings
AS RESTRICTIVE TO authenticated
USING (sso_provider_id = (
SELECT auth.jwt()#>>'{amr,0,provider}'
));
auth.jwt()#>>'{amr,0,provider}' extracts the provider ID of the login method from the JWT. For SSO login, this value is the sso_provider_id.
Performance Optimization Points
RLS Policy is an implicit WHERE clause. Every query automatically adds it, and it may contain subqueries. This affects performance.
Several optimization suggestions:
1. Create indexes for fields in Policy
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
2. Avoid subqueries in Policy
Subqueries execute once for each row of data, with high overhead. A better approach is to store tenant_id in JWT and extract directly using auth.jwt():
-- Not recommended: subquery
USING (tenant_id = (SELECT tenant_id FROM users WHERE id = auth.uid()))
-- Recommended: store tenant_id in JWT
USING (tenant_id = (auth.jwt()->>'tenant_id')::uuid)
We’ll discuss later how to put tenant_id into JWT using Custom Access Token Hook.
3. Use SECURITY DEFINER functions to encapsulate complex logic
Complex logic in Policy can be encapsulated into functions, set as SECURITY DEFINER to avoid repeated execution:
CREATE FUNCTION current_tenant_id() RETURNS UUID
LANGUAGE SQL STABLE SECURITY DEFINER AS $$
SELECT tenant_id FROM users WHERE id = auth.uid();
$$;
-- Call function in Policy
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE USING (tenant_id = current_tenant_id());
STABLE means the function returns the same value within the same transaction, and PostgreSQL will optimize to execute only once.
IV. Custom Claims and RBAC Implementation
JWT by default only has Supabase built-in fields: user ID, email, role (authenticated/anon), etc. But often you need more information—user role (admin/moderator), tenant ID, permission list.
Supabase provides Custom Access Token Hook, allowing you to modify JWT content before it’s issued.
Why Custom Claims Are Needed
Several typical scenarios:
- RBAC permission control: Store user role in JWT, RLS Policy judges permissions based on role
- Multi-tenant isolation: Store tenant_id in JWT to avoid subqueries in Policy
- Reduce JWT size: Default JWT contains many fields, transmission overhead is large in SSR scenarios, can remove unnecessary ones
Supabase’s JWT has quite a few default fields (session_id, aal, amr, etc.), every request must carry them. If your application has many SSR pages, JWT transmits in cookies, and size affects performance.
Custom Access Token Hook Implementation
Hook is a PL/pgSQL function that executes before JWT generation. You can add, modify, delete claims.
-- Create Hook function
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
claims jsonb;
user_role text;
tenant_id uuid;
BEGIN
-- Get claims from event
claims := event->'claims';
-- Get user role from user_roles table
SELECT role INTO user_role
FROM public.user_roles
WHERE user_id = (event->>'user_id')::uuid;
-- If user has role, add to claims
IF user_role IS NOT NULL THEN
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
END IF;
-- Get tenant_id from users table
SELECT tenant_id INTO tenant_id
FROM public.users
WHERE id = (event->>'user_id')::uuid;
IF tenant_id IS NOT NULL THEN
claims := jsonb_set(claims, '{tenant_id}', to_jsonb(tenant_id));
END IF;
-- Return modified claims
RETURN jsonb_build_object('claims', claims);
END;
$$;
-- Grant supabase_auth_admin permission to execute this function
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb)
TO supabase_auth_admin;
-- Enable this Hook in Supabase Dashboard
-- Authentication > Hooks > Custom Access Token > select the above function
Hook’s input parameter event contains:
user_id: Current user UUIDclaims: Current JWT claimsauthentication_method: Login method (password, oauth, sso/saml, etc.)
The returned claims will be merged into the final JWT.
RBAC Table Structure Design
A complete role permission system needs several tables:
-- Define role types
CREATE TYPE app_role AS ENUM ('admin', 'moderator', 'user');
-- Define permission types
CREATE TYPE app_permission AS ENUM (
'posts.delete', -- Delete any post
'posts.pin', -- Pin post
'users.manage', -- Manage users
'settings.edit' -- Edit settings
);
-- User-role table
CREATE TABLE public.user_roles (
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role app_role NOT NULL,
PRIMARY KEY (user_id, role)
);
-- Role-permission table
CREATE TABLE public.role_permissions (
role app_role NOT NULL,
permission app_permission NOT NULL,
PRIMARY KEY (role, permission)
);
-- Insert default permissions
INSERT INTO role_permissions (role, permission) VALUES
('admin', 'posts.delete'),
('admin', 'posts.pin'),
('admin', 'users.manage'),
('admin', 'settings.edit'),
('moderator', 'posts.delete'),
('moderator', 'posts.pin');
authorize() Permission Check Function
With roles and permissions tables, we also need a function to check if a user has a certain permission:
CREATE OR REPLACE FUNCTION public.authorize(
requested_permission app_permission
)
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
AS $$
DECLARE
user_role app_role;
BEGIN
-- Get user role from JWT
SELECT (auth.jwt()->>'user_role')::app_role
INTO user_role;
-- If JWT has no role, return false
IF user_role IS NULL THEN
RETURN false;
END IF;
-- Check if role has this permission
RETURN EXISTS (
SELECT 1 FROM public.role_permissions
WHERE role = user_role
AND permission = requested_permission
);
END;
$$;
-- Grant to authenticated role
GRANT EXECUTE ON FUNCTION public.authorize(app_permission)
TO authenticated;
RLS Policy Calling authorize()
Now you can use authorize() in Policy to check permissions:
-- Only admin/moderator can delete posts
CREATE POLICY "Role-based delete" ON posts
FOR DELETE TO authenticated
USING (
authorize('posts.delete') OR auth.uid() = author_id
);
-- Only admin can pin posts
CREATE POLICY "Admin pin posts" ON posts
FOR UPDATE TO authenticated
USING (
NOT is_pinned OR authorize('posts.pin')
);
The logic of the first Policy: users can delete posts if they either have posts.delete permission (admin/moderator) or are the post author.
The logic of the second Policy: when modifying posts, if setting is_pinned to true, must have posts.pin permission.
Frontend Reading Custom Claims
Custom Claims are in the JWT, frontend needs to decode access_token to read:
import { jwtDecode } from 'jwt-decode'
// Get session
const { data: { session } } = await supabase.auth.getSession()
if (session) {
const decoded = jwtDecode(session.access_token)
console.log('User role:', decoded.user_role)
console.log('Tenant ID:', decoded.tenant_id)
}
Note that jwtDecode only decodes JWT, doesn’t verify signature. Frontend doesn’t need to verify—verification is done on Supabase server side.
V. Enterprise SaaS Complete Scenario Practice
Now let’s combine what we’ve learned to build a complete enterprise SaaS authentication solution.
The scenario is: your SaaS product has two types of users:
- Enterprise users: Login through client company’s SSO (Okta/Azure AD), account belongs to a tenant
- Individual users: Login through Google/GitHub OAuth, don’t belong to any tenant, can only access public resources
Data isolation requirement: enterprise users can only see their own tenant’s data, individual users cannot see enterprise data.
User Table Design
CREATE TABLE public.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
-- Authentication source
auth_type TEXT NOT NULL DEFAULT 'oauth', -- 'oauth' or 'sso'
-- SSO user exclusive fields
sso_provider_id TEXT, -- SSO connection ID
tenant_id UUID, -- Belonging tenant
-- Basic info
full_name TEXT,
avatar_url TEXT,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Tenant table
CREATE TABLE public.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
sso_provider_id TEXT NOT NULL UNIQUE, -- Associated with SSO connection
plan_type TEXT NOT NULL DEFAULT 'team', -- 'team' or 'enterprise'
created_at TIMESTAMPTZ DEFAULT now()
);
Unified Processing in Custom Access Token Hook
Hook needs to distinguish between two types of users:
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
claims jsonb;
user_record RECORD;
BEGIN
claims := event->'claims';
-- Get user info
SELECT auth_type, sso_provider_id, tenant_id, role
INTO user_record
FROM public.users
WHERE id = (event->>'user_id')::uuid;
-- If user doesn't exist (just registered), skip
IF user_record IS NULL THEN
RETURN jsonb_build_object('claims', claims);
END IF;
-- Add authentication type
claims := jsonb_set(claims, '{auth_type}', to_jsonb(user_record.auth_type));
-- SSO user add tenant_id and sso_provider_id
IF user_record.auth_type = 'sso' THEN
IF user_record.tenant_id IS NOT NULL THEN
claims := jsonb_set(claims, '{tenant_id}', to_jsonb(user_record.tenant_id));
END IF;
IF user_record.sso_provider_id IS NOT NULL THEN
claims := jsonb_set(claims, '{sso_provider_id}', to_jsonb(user_record.sso_provider_id));
END IF;
END IF;
-- Add user role (if exists)
IF user_record.role IS NOT NULL THEN
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_record.role));
END IF;
RETURN jsonb_build_object('claims', claims);
END;
$$;
User Creation Flow After Login
Both OAuth and SSO login trigger record creation in auth.users table. We need to synchronize user information to public.users table after successful login.
You can use Auth Hooks’ mfa_verification_hook or handle in business code:
// In auth callback page, after successful login
const { data: { user } } = await supabase.auth.getUser()
if (user) {
// Check if user already exists in public.users
const { data: existingUser } = await supabase
.from('users')
.select('id')
.eq('id', user.id)
.single()
if (!existingUser) {
// Determine login method
const authType = user.app_metadata?.provider || 'oauth'
// Create user record
await supabase.from('users').insert({
id: user.id,
email: user.email,
auth_type: authType.startsWith('sso') ? 'sso' : 'oauth',
sso_provider_id: user.app_metadata?.sso_provider_id,
tenant_id: null, // Assigned by admin later
full_name: user.user_metadata?.full_name,
avatar_url: user.user_metadata?.avatar_url
})
}
}
Unified RLS Policy
Business tables need unified RLS Policy to handle both OAuth users and SSO users:
-- Assume there's a projects table
CREATE TABLE public.projects (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id),
name TEXT NOT NULL,
is_public BOOLEAN DEFAULT false,
created_by UUID REFERENCES users(id)
);
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
-- Global isolation Policy (RESTRICTIVE)
CREATE POLICY "Tenant or public access" ON public.projects
AS RESTRICTIVE TO authenticated
USING (
-- SSO users: must match tenant_id
(auth.jwt()->>'auth_type' = 'sso'
AND tenant_id = (auth.jwt()->>'tenant_id')::uuid)
OR
-- OAuth users: can only see public projects
(auth.jwt()->>'auth_type' = 'oauth' AND is_public = true)
);
-- Allow viewing (PERMISSIVE)
CREATE POLICY "Users can view" ON public.projects
FOR SELECT TO authenticated
USING (true);
-- Allow creation (tenant users only)
CREATE POLICY "Tenant users can create" ON public.projects
FOR INSERT TO authenticated
WITH CHECK (
auth.jwt()->>'auth_type' = 'sso'
AND tenant_id = (auth.jwt()->>'tenant_id')::uuid
);
-- Allow editing (creator or admin)
CREATE POLICY "Creators or admins can edit" ON public.projects
FOR UPDATE TO authenticated
USING (
created_by = auth.uid()
OR authorize('projects.edit')
);
The logic of this Policy set:
- SSO users can only see their own tenant’s projects
- OAuth users can only see public projects
- Only SSO users can create projects (need to belong to tenant)
- Edit permission: creator or admin with
projects.editpermission
Enterprise Client Admin Assigning Tenants
When SSO users first log in, tenant_id is null. Need enterprise admin to manually assign:
-- Admin adds user to tenant
UPDATE public.users
SET tenant_id = '<tenant-UUID>'
WHERE id = '<user-UUID>';
-- Set user role
INSERT INTO public.user_roles (user_id, role)
VALUES ('<user-UUID>', 'admin');
This process can be made into an admin interface, or handled automatically using Auth Hooks (anonymously map to tenant based on SSO provider).
Complete Flow Diagram
User Login
│
├─ OAuth User
│ │
│ ├─ Google/GitHub Callback
│ ├─ Supabase creates auth.users
│ ├─ Business code creates public.users (auth_type='oauth')
│ └─ JWT: { auth_type: 'oauth' }
│ │
│ └─ RLS: can only see is_public=true data
│
└─ SSO User
│
├─ Okta/Azure AD SAML Callback
├─ Supabase creates auth.users (with sso_provider_id)
├─ Business code creates public.users (auth_type='sso')
├─ Admin assigns tenant_id
├─ Custom Hook adds tenant_id to JWT
└─ JWT: { auth_type: 'sso', tenant_id: '...' }
│
└─ RLS: can only see tenant_id matching data
Supabase Auth Enterprise Configuration Complete Flow
Complete configuration steps from OAuth social login to SAML SSO enterprise integration, to RLS multi-tenant permission isolation
⏱️ Estimated time: 2 hr
- 1
Step1: Configure OAuth Provider (Google/GitHub/Apple)
1. Create OAuth App in Provider console (Google Cloud Console / GitHub Settings)
2. Set callback URL: https://<project-ref>.supabase.co/auth/v1/callback
3. Enable Provider in Supabase Dashboard, fill in Client ID and Secret
4. Call signInWithOAuth() in code, specify scopes and redirectTo - 2
Step2: Configure SAML SSO Enterprise Integration
1. Get Metadata file or URL from enterprise IdP (Okta/Azure AD)
2. Use CLI to add SSO connection: supabase sso add --type saml --domains company.com
3. Configure Attribute Mapping, map email, name and other fields
4. Test login flow: user enters email then automatically redirects to IdP - 3
Step3: Implement RLS Multi-Tenant Isolation
1. Add tenant_id field to business tables
2. Enable RLS: ALTER TABLE projects ENABLE ROW LEVEL SECURITY
3. Create RESTRICTIVE Policy for global tenant filtering
4. Create PERMISSIVE Policy for specific operation permissions (SELECT/INSERT/UPDATE)
5. Add index to tenant_id field for performance optimization - 4
Step4: Configure Custom Access Token Hook
1. Create public.custom_access_token_hook() function
2. Add tenant_id, user_role and other custom claims in function
3. Grant supabase_auth_admin execution permission
4. Enable Hook in Supabase Dashboard (Authentication > Hooks)
5. Frontend reads custom claims through jwtDecode() - 5
Step5: Implement RBAC Permission Control
1. Create user_roles and role_permissions tables
2. Define app_role and app_permission enum types
3. Create authorize() function to check user permissions
4. Call authorize('permission.name') in RLS Policy
5. Frontend shows/hides UI elements based on user_role in JWT
FAQ
What's the difference between OAuth and SAML SSO? Which should I choose?
If you're doing B2B SaaS, SSO is a must-have feature; if it's a personal product, OAuth is sufficient.
After enabling RLS, queries return empty results. What should I do?
RLS Policy performance is poor. How to optimize?
• Create indexes for fields in Policy: CREATE INDEX idx_tenant ON projects(tenant_id);
• Avoid subqueries in Policy: Use Custom Access Token Hook to put tenant_id in JWT, directly extract with auth.jwt()->>'tenant_id'
• Use SECURITY DEFINER functions to encapsulate complex logic, PostgreSQL will optimize to execute only once
How to store custom fields (like tenant_id, user_role) in JWT?
1. Create PL/pgSQL function custom_access_token_hook(event jsonb)
2. Add custom claims using jsonb_set() in function
3. Enable Hook in Supabase Dashboard (Authentication > Hooks > Custom Access Token)
4. Frontend reads custom claims in JWT using jwtDecode()
How to implement data isolation between enterprise users and individual users in multi-tenant SaaS?
• SSO users: can only see tenant_id matching tenant data
• OAuth users: can only see is_public=true public data
Use RESTRICTIVE Policy as global filter, PERMISSIVE Policy controls specific operations.
Does Supabase support Apple Sign In? Is configuration complex?
1. Create Services ID in Apple Developer Portal
2. Generate private key (.p8 file, can only download once)
3. Fill in Team ID, Key ID, Services ID and private key content in Supabase Dashboard
iOS apps can use native Sign in with Apple API, get identity_token and pass to Supabase's signInWithIdTokenCredentials().
Enterprise client requires SSO but I don't know their IdP type. What should I do?
We started from OAuth social login, moved to SAML SSO enterprise integration, and finally built a complete permission system with RLS and Custom Claims. This solution can cover various scenarios from personal products to enterprise SaaS.
Several key decision points:
If your users are primarily individual consumers: OAuth (Google/GitHub) is sufficient. Simple configuration, user familiarity, low maintenance cost. Basic RLS version—users can only access their own data—is enough.
If you’re doing B2B SaaS: SSO is a must-have feature. Enterprise clients will require it, without it you might be eliminated directly. Prepare integrations with Okta/Azure AD/Google Workspace, reserve multi-tenant architecture.
If you’re building complex enterprise applications: RBAC + RLS combination is the standard solution. Role permission tables, Custom Claims, authorize() function—this combo can handle refined permission requirements, like “admins can delete but not pin” or “tenant members can edit but not delete”.
Next step suggestion: Start with OAuth, get the basic flow working. When the project matures and has enterprise client needs, then add SSO and RBAC. Supabase’s architecture supports gradual upgrades, no need to configure everything at the beginning.
13 min read · Published on: Apr 21, 2026 · Modified on: Apr 25, 2026
Supabase in Practice
If you landed here from search, the fastest way to build context is to jump to the previous or next post in this same series.
Previous
Supabase Edge Functions in Practice: Deno Runtime and TypeScript Development Guide
Deep dive into Supabase Edge Functions development: understand Deno runtime architecture and V8 isolate principles, master CLI command workflows, build RESTful APIs with Hono framework - a complete guide from local debugging to production deployment
Part 5 of 8
Next
Supabase Storage in Practice: File Uploads, CDN, and Access Control
A complete practical guide to Supabase Storage: comparison of three access control modes, TUS chunked uploads, Smart CDN optimization tips, and cost analysis against R2/S3. Includes React code examples and troubleshooting solutions.
Part 7 of 8
Related Posts
Supabase Getting Started: PostgreSQL + Auth + Storage All-in-One Backend
Supabase Getting Started: PostgreSQL + Auth + Storage All-in-One Backend
Supabase Database Design: Tables, Relationships & Row Level Security Guide
Supabase Database Design: Tables, Relationships & Row Level Security Guide
Supabase Auth in Practice: Email Verification, OAuth & Session Management

Comments
Sign in with GitHub to leave a comment