Guide

Getting Started with OAuth 2.1 Server

Learn how to enable OAuth 2.1 and register client applications in your Zuvo project

This guide will walk you through setting up your Zuvo project as an OAuth 2.1 identity provider, from enabling the feature to registering your first client application.

Prerequisites

Before you begin, make sure you have:

  • A Zuvo project (create one at zuvodev.com)
  • Admin access to your project
  • (Optional) Zuvo CLI v2.54.11 or higher for local development

Overview

Setting up OAuth 2.1 in your Zuvo project involves these steps:

  1. Enable OAuth 2.1 server capabilities in your project
  2. Configure your authorization path
  3. Build your authorization UI (frontend)
  4. Register OAuth client applications

Enable OAuth 2.1 server

OAuth 2.1 server is currently in beta and free to use during the beta period on all Zuvo plans.

Cloud
  1. Go to your project dashboard
  2. Navigate to Authentication > OAuth Server in the sidebar
  3. Enable OAuth 2.1 server capabilities
CLI

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 Zuvo instance:

supabase start
# or if already running:
supabase stop && supabase start

Once enabled, your project will expose the necessary OAuth endpoints:

Cloud
EndpointURL
Authorization endpointhttps://<project-ref>.supabase.co/auth/v1/oauth/authorize
Token endpointhttps://<project-ref>.supabase.co/auth/v1/oauth/token
JWKS endpointhttps://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
Discovery endpointhttps://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1
OIDC discoveryhttps://<project-ref>.supabase.co/auth/v1/.well-known/openid-configuration
CLI
EndpointURL
Authorization endpointhttp://localhost:54321/auth/v1/oauth/authorize
Token endpointhttp://localhost:54321/auth/v1/oauth/token
JWKS endpointhttp://localhost:54321/auth/v1/.well-known/jwks.json
Discovery endpointhttp://localhost:54321/.well-known/oauth-authorization-server/auth/v1
OIDC discoveryhttp://localhost:54321/auth/v1/.well-known/openid-configuration

Expose local instance to the world

To test OAuth flows with external applications, you can expose your local Zuvo 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.

Configure your authorization path

Before registering clients, you need to configure where your authorization UI will live.

  1. In your project dashboard, navigate to Authentication > OAuth Server
  2. Set the Authorization Path (e.g., /oauth/consent)

Your authorization UI will be at the combined Site URL + Authorization Path. For example:

  • Site URL: https://example.com (from Authentication > URL Configuration)
  • Authorization Path: /oauth/consent (from OAuth Server settings)
  • Your authorization UI: https://example.com/oauth/consent

When OAuth clients initiate the authorization flow, Zuvo Auth will redirect users to this URL with an authorization_id query parameter. You'll use Zuvo JavaScript library OAuth methods to handle the authorization:

  • supabase.auth.oauth.getAuthorizationDetails(authorization_id) - Retrieve client and authorization details
  • supabase.auth.oauth.approveAuthorization(authorization_id) - Approve the authorization request
  • supabase.auth.oauth.denyAuthorization(authorization_id) - Deny the authorization request

Build your authorization UI

This 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:

  1. Extract authorization_id - Get the authorization_id from the URL query parameters
  2. Authenticate the user - If not already logged in, redirect to your login page (preserving the authorization_id)
  3. Retrieve authorization details - Use supabase.auth.oauth.getAuthorizationDetails(authorization_id) to get client information including requested scopes
  4. Display consent screen - Show the user what app is requesting access and what scopes/permissions are being requested
  5. Handle user decision - Call either approveAuthorization(authorization_id) or denyAuthorization(authorization_id) based on user choice

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

Example authorization UI

Here's how to build a minimal authorization page at your configured path (e.g., /oauth/consent):

Next.js

The Zuvo Auth SDK contains three different functions for authenticating user access to applications:

Summary of the methods

  • Use getClaims to protect pages and user data. It reads the access token from storage and verifies it. Locally via the WebCrypto API and a cached JWKS endpoint when the project uses asymmetric signing keys (the default for new projects), or by calling getUser solely to validate when symmetric keys are in use. The returned claims always come from decoding the JWT, not from a user lookup.
  • getUser makes a network call to the project's Auth instance to get the user record, which includes the most up-to-date information about the user at the cost of a network call.
  • getSession when you need the raw session (the access token, refresh token, and expiry). For example to forward the access token to another service. The session is loaded directly from local storage and isn't re-validated against the Auth server, so the embedded user object shouldn't be trusted on its own when storage is shared with the client (cookies, request headers). To verify identity, validate the access token with getClaims, or call getUser for a fresh, server-confirmed user record.

In summary: use getClaims to verify identity (typically for protecting pages and data), getUser when you need an up-to-date user record from the Auth server, and getSession when you need the access or refresh token directly, but don't rely on the user object it returns for authorization decisions.

// 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_PUBLISHABLE_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 } = await supabase.auth.getClaims()
  const claims = data?.claims

  if (!claims) {
    // 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>
  }

  // if no authorization_id returned, user has previously consented, redirect them
  if (!('authorization_id' in authDetails)) {
    redirect(authDetails['redirect_url'])
  }

  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_PUBLISHABLE_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_url)
  } 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_url)
  }
}
React (SPA)
// 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_url
    }
  }

  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_url
    }
  }

  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>
  )
}

How it works

  1. User navigates to your authorization path - When a third-party app initiates OAuth, Zuvo Auth redirects the user to your configured authorization path (e.g., https://example.com/oauth/consent?authorization_id=<id>)
  2. Extract authorization_id - Your page extracts the authorization_id from the URL query parameters
  3. Check authentication - Your page checks if the user is logged in, redirecting to login if not (preserving the authorization_id)
  4. Retrieve details - Call supabase.auth.oauth.getAuthorizationDetails(authorization_id) to get information about the requesting client
  5. Show consent screen - Display a UI asking the user to approve or deny access
  6. Handle decision - When the user clicks approve/deny:
    • Call supabase.auth.oauth.approveAuthorization(authorization_id) or denyAuthorization(authorization_id)
    • These methods handle all OAuth logic internally (generating authorization codes, etc.)
    • They return a redirect_url URL
  7. Redirect back - Redirect the user to the redirect_url URL, which sends them back to the third-party app with either an authorization code (approved) or error (denied)

Register an OAuth client

Before third-party applications can use your project as an identity provider, you need to register them as OAuth clients.

Dashboard
  1. Go to Authentication > OAuth Apps (under the Manage section)
  2. Click Add a new client
  3. Enter the client details:
    • Client name: A friendly name for your application
    • Redirect URIs: One or more URLs where users will be redirected after authorization
    • Client type: Choose between:
      • Public - For mobile and single-page apps (no client secret)
      • Confidential - For server-side apps (includes client secret)
  4. Click Create

You'll receive:

  • Client ID: A unique identifier for the client
  • Client Secret (for confidential clients): A secret key for authenticating the client

Token endpoint authentication method

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:

MethodDescriptionUsed by
noneNo client authentication. Only client_id is sent in the request body.Public clients (required)
client_secret_basicClient credentials sent via HTTP Basic auth (Authorization: Basic <base64(client_id:client_secret)>). This is the default for confidential clients.Confidential clients
client_secret_postClient 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.

Programmatically

You can register clients programmatically using the SDK admin endpoints or by calling your project's auth server admin endpoint directly.

JavaScript
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://your-project-id.supabase.co',
  'sb_secret_...' // Use the secret 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!
}
cURL

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"
}

List OAuth clients

To view all registered OAuth clients:

JavaScript
const { data, error } = await supabase.auth.admin.oauth.listClients()

if (error) {
  console.error('Error listing clients:', error)
} else {
  console.log('OAuth clients:', data)
}
cURL

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}"

Customizing tokens (optional)

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.

Common use cases

  • Customize audience claim: Set the aud claim to the third-party API endpoint for proper JWT validation
  • Add client-specific permissions: Include custom claims based on which OAuth client is requesting access
  • Implement dynamic scopes: Add metadata that RLS policies can use for fine-grained access control

For more examples, see Token Security & RLS.

Redirect URI configuration

Redirect URIs are critical for OAuth security. Zuvo Auth will only redirect to URIs that are explicitly registered with the client.

Best practices

  • Use HTTPS in production - Always use HTTPS for redirect URIs in production
  • Register exact, complete URLs - Each redirect URI must be the full URL including protocol, domain, path, and port if needed
  • Use separate OAuth clients per environment - Create separate OAuth clients for development, staging, and production. This provides better security isolation, allows independent secret rotation, and improves auditability. If you need to use the same client across environments, you can register multiple redirect URIs, but separate clients are recommended.

Next steps

Now that you've registered your first OAuth client, you're ready to: