Files
supabase/apps/docs/content/guides/auth/oauth-server/token-security.mdx
2026-05-14 15:56:41 +01:00

444 lines
12 KiB
Plaintext

---
id: 'oauth-server-token-security'
title: 'Token Security and Row Level Security'
description: 'Secure your data with Row Level Security policies for OAuth clients'
---
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>
## How OAuth tokens work with RLS
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.
### Token structure
Every OAuth access token includes:
```json
{
"sub": "user-uuid",
"role": "authenticated",
"aud": "authenticated",
"user_id": "user-uuid",
"email": "user@example.com",
"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.
## Extracting OAuth claims in RLS
Use the `auth.jwt()` function to access token claims in your policies:
```sql
-- 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'
```
## Common RLS patterns for OAuth
### Pattern 1: Grant specific client full access
Allow a specific OAuth client to access all user data:
```sql
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'
);
```
### Pattern 2: Grant multiple clients read-only access
Allow several OAuth clients to read data, but not modify it:
```sql
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'
)
);
```
### Pattern 3: Restrict sensitive data from OAuth clients
Prevent OAuth clients from accessing sensitive data:
```sql
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
);
```
### Pattern 4: Client-specific data access
Different clients access different subsets of data:
```sql
-- 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'
);
```
## Real-world examples
### Example 1: Multi-platform application
You have a web app, mobile app, and third-party integrations:
```sql
-- 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
[Custom Access Token Hooks](/docs/guides/auth/auth-hooks/custom-access-token-hook) 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.
<Admonition type="note">
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.
</Admonition>
### Customizing the audience claim
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:
```typescript
Deno.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:
- **JWT validation by third parties**: Services can verify tokens were issued for their specific API
- **Multi-tenant applications**: Different audiences for different client applications
- **Compliance**: Meeting security requirements that mandate audience validation
### Adding client-specific claims
You can also add custom claims and metadata based on the OAuth client:
```typescript
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
Deno.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:
```sql
-- 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'
)
);
```
## Security best practices
### 1. Principle of least privilege
Grant OAuth clients only the minimum permissions they need:
```sql
-- 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'
);
```
### 2. Separate policies for OAuth clients
Create dedicated policies for OAuth clients rather than mixing them with user policies:
```sql
-- 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')
);
```
### 3. Regularly audit OAuth clients
Track and review which clients have access:
```sql
-- 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;
```
## Testing your policies
Always test your RLS policies before deploying to production:
```sql
-- 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](/dashboard/project/_/auth/policies).
## Troubleshooting
### Policy not working for OAuth client
**Problem**: OAuth client can't access data despite having a valid token.
**Check**:
1. Verify the policy includes the client's `client_id`
2. Ensure RLS is enabled on the table
3. Check for conflicting restrictive policies
4. Test with secret key to isolate RLS issues
```sql
-- 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;
```
### Policy too permissive
**Problem**: OAuth client has access to data it shouldn't.
**Solution**: Use `AS RESTRICTIVE` policies to add additional constraints:
```sql
-- 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
);
```
### Can't differentiate between users and OAuth clients
**Problem**: Need to apply different logic for direct user sessions vs OAuth.
**Solution**: Check if `client_id` is present:
```sql
-- 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
);
```
## Next steps
- [Learn about JWTs](/docs/guides/auth/jwts) - Deep dive into Supabase token structure
- [Row Level Security](/docs/guides/auth/row-level-security) - Complete RLS guide
- [Custom Access Token Hooks](/docs/guides/auth/auth-hooks/custom-access-token-hook) - Inject custom claims
- [OAuth flows](/docs/guides/auth/oauth-server/oauth-flows) - Understand token issuance