mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## Problem The Docs E2E link checker found broken links throughout docs, starting with `phone-login.mdx` pointing to `/docs/guides/cli/config` (404). Old links like `/docs/guides/cli/config` still work on the live site because `supabase.com` has redirects set up for them, but these links break on the docs preview site, which is what the E2E check tests against. These issues look clean on the live site, and I didn't catch them in my first pass because I was testing production instead of the preview. The E2E check only tests the ~20 pages a given PR happens to touch, so fixing the pages it flagged kept exposing more of the same problem one page at a time as each fix pulled in a new file. To stop chasing this incrementally, I cross-referenced every `/docs/guides/*` and `/docs/reference/*` redirect source in `apps/www/lib/redirects.js` against actual usage across all of `apps/docs`, and verified each candidate against the live preview. ## Solution Rather than updating the Docs E2E link checker, this PR resolves the links. **Why:** we own these docs, so keeping the links clean without redirects is keeping the house maintained. See [Broken Window Theory](https://blog.codinghorror.com/the-broken-window-theory/). Updated every link still using an old path to point straight at the current page instead of relying on a redirect. This covers old links like: - `/docs/guides/cli/config` → `/docs/guides/local-development/cli/config` - `/docs/guides/cli/getting-started` → `/docs/guides/local-development/cli/getting-started` - `/docs/guides/cli/local-development` → `/docs/guides/local-development/database-migrations` - `/docs/guides/cli/managing-environments` → `/docs/guides/deployment/managing-environments` - `/docs/guides/cli/seeding-your-database` → `/docs/guides/local-development/seeding-your-database` - bare `/docs/guides/cli` → `/docs/guides/local-development` - `/docs/guides/platform/compute-add-ons` → `/docs/guides/platform/compute-and-disk` - `/docs/guides/platform/shared-responsibility-model` → `/docs/guides/deployment/shared-responsibility-model` - `/docs/guides/database` → `/docs/guides/database/overview` - `/docs/reference/javascript`, `/docs/reference/dart`, `/docs/reference/kotlin`, `/docs/reference/python`, `/docs/reference/csharp` → their `/introduction` pages (the redirect's own destination, `/start`, turned out to be dead even on production — a separate bug in `redirects.js` I didn't touch here) - and about 35 more of the same pattern, listed in the commit messages Also fixed a handful of dead heading anchors found along the way (links that resolve to the right page but point at a `#section` that got renamed or moved), including the original `#bigquery` anchor and a few in `connecting-to-postgres.mdx` where content moved to its own dedicated page. Left alone on purpose: - `content/guides/cli.mdx` — this page has no route in the docs app at all (no `app/guides/cli/` directory), so it 404s even in production before the `www` redirect ever fires. Fixing its internal link wouldn't change that; it needs an actual routing/content decision, not a link fix. - A few candidates that already resolve fine as-is (`pg_partman`, bare `/docs/reference/api`, bare `/docs/reference/cli`) — confirmed via curl, left untouched. ## Manual testing 1. Confirmed every new link target actually exists by checking the destination file/page and matching heading anchors. 2. Cross-referenced every `/docs/guides/*` and `/docs/reference/*` redirect source in `apps/www/lib/redirects.js` against real usage in `apps/docs`, and curl-verified each old path (404) and new path (200) against the live PR preview before fixing it. 3. Ran the Docs E2E link checker locally against changed pages. 4. Spot-checked the original broken link from CI (`/docs/guides/cli/config`) to confirm it now points to a working page.
444 lines
12 KiB
Plaintext
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/_/database/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/database/postgres/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
|