apps/docs/content/guides/auth/oauth-server/getting-started.mdx
This guide will walk you through setting up your Supabase project as an OAuth 2.1 identity provider, from enabling the feature to registering your first client application.
Before you begin, make sure you have:
Setting up OAuth 2.1 in your Supabase project involves these steps:
Testing OAuth flows is often easier on a Supabase project since it's already accessible on the web, no tunnel or additional configuration needed.
</Admonition>OAuth 2.1 server is currently in beta and free to use during the beta period on all Supabase plans.
<Tabs scrollable size="small" type="underlined" defaultActiveId="dashboard" queryGroup="oauth-setup"
<TabPanel id="dashboard" label="Cloud">
Edit your supabase/config.toml file and add the OAuth server configuration:
[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
allow_dynamic_registration = false # Optional: enable dynamic client registration
Start or restart your local Supabase instance:
supabase start
# or if already running:
supabase stop && supabase start
Once enabled, your project will expose the necessary OAuth endpoints:
<Tabs scrollable size="small" type="underlined" defaultActiveId="dashboard" queryGroup="oauth-setup"
<TabPanel id="dashboard" label="Cloud">
| Endpoint | URL |
|---|---|
| Authorization endpoint | https://<project-ref>.supabase.co/auth/v1/oauth/authorize |
| Token endpoint | https://<project-ref>.supabase.co/auth/v1/oauth/token |
| JWKS endpoint | https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json |
| Discovery endpoint | https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1 |
| OIDC discovery | https://<project-ref>.supabase.co/auth/v1/.well-known/openid-configuration |
| Endpoint | URL |
|---|---|
| Authorization endpoint | http://localhost:54321/auth/v1/oauth/authorize |
| Token endpoint | http://localhost:54321/auth/v1/oauth/token |
| JWKS endpoint | http://localhost:54321/auth/v1/.well-known/jwks.json |
| Discovery endpoint | http://localhost:54321/.well-known/oauth-authorization-server/auth/v1 |
| OIDC discovery | http://localhost:54321/auth/v1/.well-known/openid-configuration |
To test OAuth flows with external applications, you can expose your local Supabase instance using a tunnel solution (such as ngrok or Cloudflare Tunnel).
When using a tunnel, configure the jwt_issuer field in your supabase/config.toml to match your tunnel URL:
[auth]
jwt_issuer = "https://my-tunnel.url/auth/v1"
This ensures that JWTs issued by your local instance use the correct issuer claim for token validation, and serves the discovery endpoint at the correct location with accurate discovery information.
</TabPanel> </Tabs> <Admonition type="tip">Use asymmetric JWT signing keys for better security
By default, Supabase uses HS256 (symmetric) for signing JWTs. For OAuth use cases, we recommend migrating to asymmetric algorithms like RS256 or ES256. Asymmetric keys are more scalable and secure because:
Learn more about configuring JWT signing keys.
Note: If you plan to use OpenID Connect ID tokens (by requesting the openid scope), asymmetric signing algorithms are required. ID token generation will fail with HS256.
Before registering clients, you need to configure where your authorization UI will live.
/oauth/consent)The authorization path is combined with your Site URL (configured in Authentication > URL Configuration) to create the full authorization endpoint URL.
</Admonition>Your authorization UI will be at the combined Site URL + Authorization Path. For example:
https://example.com (from Authentication > URL Configuration)/oauth/consent (from OAuth Server settings)https://example.com/oauth/consentWhen OAuth clients initiate the authorization flow, Supabase Auth will redirect users to this URL with an authorization_id query parameter. You'll use Supabase JavaScript library OAuth methods to handle the authorization:
supabase.auth.oauth.getAuthorizationDetails(authorization_id) - Retrieve client and authorization detailssupabase.auth.oauth.approveAuthorization(authorization_id) - Approve the authorization requestsupabase.auth.oauth.denyAuthorization(authorization_id) - Deny the authorization requestThis is where you build the frontend for your authorization flow. When third-party apps initiate OAuth, users will be redirected to your authorization path (configured in the previous step) with an authorization_id query parameter.
Your authorization UI should:
authorization_id from the URL query parameterssupabase.auth.oauth.getAuthorizationDetails(authorization_id) to get client information including requested scopesapproveAuthorization(authorization_id) or denyAuthorization(authorization_id) based on user choiceThe authorization details include a scope field (singular) containing a space-separated string of scopes requested by the client (e.g., "openid email profile"). You should display these scopes to the user so they understand what information will be shared.
This is a frontend implementation. You're building the UI that displays the consent screen and handles user interactions. The actual OAuth token generation is handled by Supabase Auth after you call the approve/deny methods.
</Admonition>Here's how to build a minimal authorization page at your configured path (e.g., /oauth/consent):
<Tabs scrollable size="small" type="underlined" defaultActiveId="nextjs" queryGroup="framework"
<TabPanel id="nextjs" label="Next.js">
// app/oauth/consent/page.tsx
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export default async function ConsentPage({
searchParams,
}: {
searchParams: { authorization_id?: string }
}) {
const authorizationId = (await searchParams).authorization_id
if (!authorizationId) {
return <div>Error: Missing authorization_id</div>
}
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: async () => (await cookies()).getAll(),
setAll: async (cookiesToSet, _headers) => {
const cookieStore = await cookies()
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
},
},
}
)
// Check if user is authenticated
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
// Redirect to login, preserving authorization_id
redirect(`/login?redirect=/oauth/consent?authorization_id=${authorizationId}`)
}
// Get authorization details using the authorization_id
const { data: authDetails, error } =
await supabase.auth.oauth.getAuthorizationDetails(authorizationId)
if (error || !authDetails) {
return <div>Error: {error?.message || 'Invalid authorization request'}</div>
}
return (
<div>
<h1>Authorize {authDetails.client.name}</h1>
<p>This application wants to access your account.</p>
<div>
<p>
<strong>Client:</strong> {authDetails.client.name}
</p>
<p>
<strong>Redirect URI:</strong> {authDetails.redirect_uri}
</p>
{authDetails.scope && authDetails.scope.trim() && (
<div>
<strong>Requested permissions:</strong>
<ul>
{authDetails.scope.split(' ').map((scopeItem) => (
<li key={scopeItem}>{scopeItem}</li>
))}
</ul>
</div>
)}
</div>
<form action="/api/oauth/decision" method="POST">
<input type="hidden" name="authorization_id" value={authorizationId} />
<button type="submit" name="decision" value="approve">
Approve
</button>
<button type="submit" name="decision" value="deny">
Deny
</button>
</form>
</div>
)
}
// app/api/oauth/decision/route.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const formData = await request.formData()
const decision = formData.get('decision')
const authorizationId = formData.get('authorization_id') as string
if (!authorizationId) {
return NextResponse.json({ error: 'Missing authorization_id' }, { status: 400 })
}
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: async () => (await cookies()).getAll(),
setAll: async (cookiesToSet, _headers) => {
const cookieStore = await cookies()
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
},
},
}
)
if (decision === 'approve') {
const { data, error } = await supabase.auth.oauth.approveAuthorization(authorizationId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
// Redirect back to the client with authorization code
return NextResponse.redirect(data.redirect_to)
} else {
const { data, error } = await supabase.auth.oauth.denyAuthorization(authorizationId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
// Redirect back to the client with error
return NextResponse.redirect(data.redirect_to)
}
}
// src/pages/OAuthConsent.tsx
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { supabase } from './supabaseClient'
export function OAuthConsent() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const authorizationId = searchParams.get('authorization_id')
const [authDetails, setAuthDetails] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function loadAuthDetails() {
if (!authorizationId) {
setError('Missing authorization_id')
setLoading(false)
return
}
// Check if user is authenticated
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
navigate(`/login?redirect=/oauth/consent?authorization_id=${authorizationId}`)
return
}
// Get authorization details using the authorization_id
const { data, error } = await supabase.auth.oauth.getAuthorizationDetails(authorizationId)
if (error) {
setError(error.message)
} else {
setAuthDetails(data)
}
setLoading(false)
}
loadAuthDetails()
}, [authorizationId, navigate])
async function handleApprove() {
if (!authorizationId) return
const { data, error } = await supabase.auth.oauth.approveAuthorization(authorizationId)
if (error) {
setError(error.message)
} else {
// Redirect to client app
window.location.href = data.redirect_to
}
}
async function handleDeny() {
if (!authorizationId) return
const { data, error } = await supabase.auth.oauth.denyAuthorization(authorizationId)
if (error) {
setError(error.message)
} else {
// Redirect to client app with error
window.location.href = data.redirect_to
}
}
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
if (!authDetails) return <div>No authorization request found</div>
return (
<div>
<h1>Authorize {authDetails.client.name}</h1>
<p>This application wants to access your account.</p>
<div>
<p>
<strong>Client:</strong> {authDetails.client.name}
</p>
<p>
<strong>Redirect URI:</strong> {authDetails.redirect_uri}
</p>
{authDetails.scope && authDetails.scope.trim() && (
<div>
<strong>Requested permissions:</strong>
<ul>
{authDetails.scope.split(' ').map((scopeItem) => (
<li key={scopeItem}>{scopeItem}</li>
))}
</ul>
</div>
)}
</div>
<div>
<button onClick={handleApprove}>Approve</button>
<button onClick={handleDeny}>Deny</button>
</div>
</div>
)
}
https://example.com/oauth/consent?authorization_id=<id>)authorization_id from the URL query parameterssupabase.auth.oauth.getAuthorizationDetails(authorization_id) to get information about the requesting clientsupabase.auth.oauth.approveAuthorization(authorization_id) or denyAuthorization(authorization_id)redirect_to URLredirect_to URL, which sends them back to the third-party app with either an authorization code (approved) or error (denied)Before third-party applications can use your project as an identity provider, you need to register them as OAuth clients.
<Tabs scrollable size="small" type="underlined" defaultActiveId="dashboard" queryGroup="oauth-setup"
<TabPanel id="dashboard" label="Dashboard">
You'll receive:
Store the client secret securely. It will only be shown once. If you lose it, you can regenerate a new one from the OAuth Apps page.
</Admonition>When a client exchanges an authorization code or refreshes a token, it must authenticate with the token endpoint. The token_endpoint_auth_method controls how this authentication happens:
| Method | Description | Used by |
|---|---|---|
none | No client authentication. Only client_id is sent in the request body. | Public clients (required) |
client_secret_basic | Client credentials sent via HTTP Basic auth (Authorization: Basic <base64(client_id:client_secret)>). This is the default for confidential clients. | Confidential clients |
client_secret_post | Client credentials sent in the request body (client_id and client_secret as form parameters). | Confidential clients |
Defaults: Public clients default to none. Confidential clients default to client_secret_basic (per RFC 7591).
Constraints: Public clients must use none. Confidential clients cannot use none.
You can set this when registering a client via the dashboard or programmatically. See OAuth Flows for examples of each method in action.
</TabPanel> <TabPanel id="programmatically" label="Programmatically">You can register clients programmatically using the SDK admin endpoints or by calling your project's auth server admin endpoint directly.
<Tabs scrollable size="small" type="underlined" defaultActiveId="javascript" queryGroup="language"
<TabPanel id="javascript" label="JavaScript">
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-service-role-key' // Use service_role key for admin operations
)
// Create an OAuth client
const { data, error } = await supabase.auth.admin.oauth.createClient({
name: 'My Third-Party App',
redirect_uris: ['https://my-app.com/auth/callback', 'https://my-app.com/auth/silent-callback'],
client_type: 'confidential',
// Optional: defaults to 'client_secret_basic' for confidential, 'none' for public
token_endpoint_auth_method: 'client_secret_basic',
})
if (error) {
console.error('Error creating client:', error)
} else {
console.log('Client created:', data)
console.log('Client ID:', data.client_id)
console.log('Client Secret:', data.client_secret) // Store this securely!
}
from supabase import create_client
supabase = create_client(
'https://your-project.supabase.co',
'your-service-role-key' # Use service_role key for admin operations
)
# Create an OAuth client
response = supabase.auth.admin.oauth.create_client({
'name': 'My Third-Party App',
'redirect_uris': [
'https://my-app.com/auth/callback',
'https://my-app.com/auth/silent-callback'
],
'client_type': 'confidential',
# Optional: defaults to 'client_secret_basic' for confidential, 'none' for public
'token_endpoint_auth_method': 'client_secret_basic'
})
print('Client created:', response)
print('Client ID:', response.client_id)
print('Client Secret:', response.client_secret) # Store this securely!
Production:
curl -X POST 'https://<project-ref>.supabase.co/auth/v1/admin/oauth/clients' \
-H "Authorization: Bearer ${SUPABASE_SECRET_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "My Third-Party App",
"redirect_uris": [
"https://my-app.com/auth/callback",
"https://my-app.com/auth/silent-callback"
],
"client_type": "confidential",
"token_endpoint_auth_method": "client_secret_basic"
}'
Local development:
curl -X POST 'http://localhost:54321/auth/v1/admin/oauth/clients' \
-H "Authorization: Bearer ${SUPABASE_SECRET_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Local Dev App",
"redirect_uris": ["http://localhost:3000/auth/callback"],
"client_type": "confidential",
"token_endpoint_auth_method": "client_secret_post"
}'
Response:
{
"client_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"client_secret": "verysecret-1234567890abcdef...",
"name": "My Third-Party App",
"redirect_uris": ["https://my-app.com/auth/callback", "https://my-app.com/auth/silent-callback"],
"client_type": "confidential",
"token_endpoint_auth_method": "client_secret_basic",
"created_at": "2025-01-15T10:30:00.000Z"
}
For complete API documentation, see the OAuth Admin API reference.
</Admonition>To view all registered OAuth clients:
<Tabs scrollable size="small" type="underlined" defaultActiveId="javascript" queryGroup="language"
<TabPanel id="javascript" label="JavaScript">
const { data, error } = await supabase.auth.admin.oauth.listClients()
if (error) {
console.error('Error listing clients:', error)
} else {
console.log('OAuth clients:', data)
}
clients = supabase.auth.admin.oauth.list_clients()
print('OAuth clients:', clients)
Production:
curl 'https://<project-ref>.supabase.co/auth/v1/admin/oauth/clients' \
-H "Authorization: Bearer ${SUPABASE_SECRET_KEY}"
Local development:
curl 'http://localhost:54321/auth/v1/admin/oauth/clients' \
-H "Authorization: Bearer ${SUPABASE_SECRET_KEY}"
By default, OAuth access tokens include standard claims like user_id, role, and client_id. If you need to customize tokens—for example, to set a specific audience claim for third-party validation or add client-specific metadata—use Custom Access Token Hooks.
Custom Access Token Hooks are triggered for all token issuance, including OAuth flows. You can use the client_id parameter to customize tokens based on which OAuth client is requesting them.
audience claim: Set the aud claim to the third-party API endpoint for proper JWT validationFor more examples, see Token Security & RLS.
Redirect URIs are critical for OAuth security. Supabase Auth will only redirect to URIs that are explicitly registered with the client.
<Admonition type="note">Not to be confused with general redirect URLs
This section is about OAuth client redirect URIs - where to send users after they authorize third-party apps to access your Supabase project. This is different from the general Redirect URLs setting, which controls where to send users after they sign in TO your app using social providers.
</Admonition> <Admonition type="caution">Exact matches only - No wildcards or patterns
OAuth client redirect URIs require exact, complete URL matches. Unlike general redirect URLs (which support wildcards), OAuth client redirect URIs do NOT support wildcards, patterns, or partial URLs. You must register the full, exact callback URL.
</Admonition>Now that you've registered your first OAuth client, you're ready to: