apps/docs/content/guides/auth/oauth-server/token-security.mdx
When you enable OAuth 2.1 in your Supabase project, third-party applications can access user data on their behalf. Row Level Security (RLS) policies are crucial for controlling exactly what data each OAuth client can access.
<Admonition type="caution">Scopes control OIDC data, not database access
The OAuth scopes (openid, email, profile, phone) control what user information is included in ID tokens and returned by the UserInfo endpoint. They do not control access to your database tables or API endpoints.
Use RLS to define which OAuth clients can access which data, regardless of the scopes they requested.
</Admonition>OAuth access tokens issued by Supabase Auth are JWTs that include all standard Supabase claims plus OAuth-specific claims. This means your existing RLS policies continue to work, and you can add OAuth-specific logic to create granular access controls.
Every OAuth access token includes:
{
"sub": "user-uuid",
"role": "authenticated",
"aud": "authenticated",
"user_id": "user-uuid",
"email": "[email protected]",
"client_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"aal": "aal1",
"amr": [{ "method": "password", "timestamp": 1735815600 }],
"session_id": "session-uuid",
"iss": "https://<project-ref>.supabase.co/auth/v1",
"iat": 1735815600,
"exp": 1735819200
}
The key OAuth-specific claim is:
| Claim | Description |
|---|---|
client_id | Unique identifier of the OAuth client that obtained this token |
You can use this claim in RLS policies to grant different permissions to different clients.
Use the auth.jwt() function to access token claims in your policies:
-- Get the client ID from the token
(auth.jwt() ->> 'client_id')
-- Check if the token is from an OAuth client
(auth.jwt() ->> 'client_id') IS NOT NULL
-- Check if the token is from a specific client
(auth.jwt() ->> 'client_id') = 'mobile-app-client-id'
Allow a specific OAuth client to access all user data:
CREATE POLICY "Mobile app can access user data"
ON user_data FOR ALL
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') = 'mobile-app-client-id'
);
Allow several OAuth clients to read data, but not modify it:
CREATE POLICY "Third-party apps can read profiles"
ON profiles FOR SELECT
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') IN (
'analytics-client-id',
'reporting-client-id',
'dashboard-client-id'
)
);
Prevent OAuth clients from accessing sensitive data:
CREATE POLICY "OAuth clients cannot access payment info"
ON payment_methods FOR ALL
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') IS NULL -- Only direct user sessions
);
Different clients access different subsets of data:
-- Analytics client can only read aggregated data
CREATE POLICY "Analytics client reads summaries"
ON user_metrics FOR SELECT
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') = 'analytics-client-id'
);
-- Admin client can read and modify all data
CREATE POLICY "Admin client full access"
ON user_data FOR ALL
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') = 'admin-client-id'
);
You have a web app, mobile app, and third-party integrations:
-- Web app: Full access
CREATE POLICY "Web app full access"
ON profiles FOR ALL
USING (
auth.uid() = user_id AND
(
(auth.jwt() ->> 'client_id') = 'web-app-client-id'
OR (auth.jwt() ->> 'client_id') IS NULL -- Direct user sessions
)
);
-- Mobile app: Read-only access to profiles
CREATE POLICY "Mobile app reads profiles"
ON profiles FOR SELECT
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') = 'mobile-app-client-id'
);
-- Third-party integration: Limited data access
CREATE POLICY "Integration reads public data"
ON profiles FOR SELECT
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') = 'integration-client-id' AND
is_public = true
);
Custom Access Token Hooks work with OAuth tokens, allowing you to inject custom claims based on the OAuth client. This is particularly useful for customizing standard JWT claims like audience (aud) or adding client-specific metadata.
Custom Access Token Hooks are triggered for all token issuance. Use client_id or authentication_method (oauth_provider/authorization_code for OAuth flows) to differentiate OAuth from regular authentication.
A common use case is customizing the audience claim for different OAuth clients. This allows third-party services to validate that tokens were issued specifically for them:
import { serve } from 'https://deno.land/[email protected]/http/server.ts'
serve(async (req) => {
const { user, claims, client_id } = await req.json()
// Customize audience based on OAuth client
if (client_id === 'mobile-app-client-id') {
return new Response(
JSON.stringify({
claims: {
aud: 'https://api.myapp.com',
app_version: '2.0.0',
},
}),
{ headers: { 'Content-Type': 'application/json' } }
)
}
if (client_id === 'analytics-partner-id') {
return new Response(
JSON.stringify({
claims: {
aud: 'https://analytics.partner.com',
access_level: 'read-only',
},
}),
{ headers: { 'Content-Type': 'application/json' } }
)
}
// Default audience for non-OAuth flows
return new Response(JSON.stringify({ claims: {} }), {
headers: { 'Content-Type': 'application/json' },
})
})
The audience claim is especially important for:
You can also add custom claims and metadata based on the OAuth client:
import { serve } from 'https://deno.land/[email protected]/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
serve(async (req) => {
const { user, claims, client_id } = await req.json()
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SECRET_KEY')!)
// Add custom claims based on OAuth client
let customClaims = {}
if (client_id === 'mobile-app-client-id') {
customClaims.aud = 'https://mobile.myapp.com'
customClaims.app_version = '2.0.0'
customClaims.platform = 'mobile'
} else if (client_id === 'analytics-client-id') {
customClaims.aud = 'https://analytics.myapp.com'
customClaims.read_only = true
customClaims.data_retention_days = 90
} else if (client_id?.startsWith('mcp-')) {
// MCP AI agents
const { data: agent } = await supabase
.from('approved_ai_agents')
.select('name, max_data_retention_days')
.eq('client_id', client_id)
.single()
customClaims.aud = `https://mcp.myapp.com/${client_id}`
customClaims.ai_agent = true
customClaims.agent_name = agent?.name
customClaims.max_retention = agent?.max_data_retention_days
}
return new Response(JSON.stringify({ claims: customClaims }), {
headers: { 'Content-Type': 'application/json' },
})
})
Use these custom claims in RLS:
-- Policy based on custom claims
CREATE POLICY "Read-only clients cannot modify"
ON user_data FOR UPDATE
USING (
auth.uid() = user_id AND
(auth.jwt() -> 'user_metadata' ->> 'read_only')::boolean IS NOT TRUE
);
-- Policy based on audience claim
CREATE POLICY "Only specific audience can access"
ON api_data FOR SELECT
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'aud') IN (
'https://api.myapp.com',
'https://mobile.myapp.com'
)
);
Grant OAuth clients only the minimum permissions they need:
-- Bad: Grant all access by default
CREATE POLICY "OAuth clients full access"
ON user_data FOR ALL
USING (auth.uid() = user_id);
-- Good: Grant specific access per client
CREATE POLICY "Specific client specific access"
ON user_data FOR SELECT
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') = 'trusted-client-id'
);
Create dedicated policies for OAuth clients rather than mixing them with user policies:
-- User access
CREATE POLICY "Users access their own data"
ON user_data FOR ALL
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') IS NULL
);
-- OAuth client access (separate policy)
CREATE POLICY "OAuth clients limited access"
ON user_data FOR SELECT
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') IN ('client-1', 'client-2')
);
Track and review which clients have access:
-- View all active OAuth clients
SELECT
oc.client_id,
oc.name,
oc.created_at,
COUNT(DISTINCT s.user_id) as active_users
FROM auth.oauth_clients oc
LEFT JOIN auth.sessions s ON s.client_id = oc.client_id
WHERE s.created_at > NOW() - INTERVAL '30 days'
GROUP BY oc.client_id, oc.name, oc.created_at;
Always test your RLS policies before deploying to production:
-- Test as a specific OAuth client
SET request.jwt.claims = '{
"sub": "test-user-uuid",
"role": "authenticated",
"client_id": "test-client-id"
}';
-- Test queries
SELECT * FROM user_data WHERE user_id = 'test-user-uuid';
-- Reset
RESET request.jwt.claims;
Or use the Supabase Dashboard's RLS policy tester.
Problem: OAuth client can't access data despite having a valid token.
Check:
client_id-- Debug: See what client_id is in the token
SELECT auth.jwt() ->> 'client_id';
-- Debug: Test without RLS
SET LOCAL role = service_role;
SELECT * FROM your_table;
Problem: OAuth client has access to data it shouldn't.
Solution: Use AS RESTRICTIVE policies to add additional constraints:
-- This policy runs in addition to permissive policies
CREATE POLICY "Restrict OAuth clients"
ON sensitive_data
AS RESTRICTIVE
FOR ALL
TO authenticated
USING (
-- OAuth clients cannot access this table at all
(auth.jwt() ->> 'client_id') IS NULL
);
Problem: Need to apply different logic for direct user sessions vs OAuth.
Solution: Check if client_id is present:
-- Direct user sessions (no OAuth)
CREATE POLICY "Direct users full access"
ON user_data FOR ALL
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') IS NULL
);
-- OAuth clients (limited access)
CREATE POLICY "OAuth clients read only"
ON user_data FOR SELECT
USING (
auth.uid() = user_id AND
(auth.jwt() ->> 'client_id') IS NOT NULL
);