# Token Security and Row Level Security

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.

**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.

## 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.

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.

### 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
import { serve } from 'https://deno.land/std@0.168.0/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:

- **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 { 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, 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