Zuvo Auth implements OAuth 2.1 with OpenID Connect (OIDC), supporting the authorization code flow with PKCE and refresh token flow. This guide explains how these flows work in detail.
Supported grant types
Zuvo Auth supports two OAuth 2.1 grant types:
- Authorization Code with PKCE (
authorization_code) - For obtaining initial access tokens - Refresh Token (
refresh_token) - For obtaining new access tokens without re-authentication
Authorization code flow with PKCE
The authorization code flow with PKCE (Proof Key for Code Exchange) is the recommended flow for all OAuth clients, including single-page applications, mobile apps, and server-side applications.
How it works
The flow consists of several steps:
- Client initiates authorization - Third-party app redirects user to Zuvo Auth's authorize endpoint
- Zuvo validates and redirects - Zuvo Auth validates OAuth parameters and redirects user to your configured authorization URL
- User authenticates and authorizes - Your frontend checks if user is logged in, shows consent screen, and handles approval/denial
- Authorization code issued - Zuvo Auth generates a short-lived authorization code and redirects back to client
- Code exchange - Client exchanges the code for tokens
- Access granted - Client receives access token, refresh token, and ID token
Flow diagram
Here's a visual representation of the complete authorization code flow:
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ │ │ │ │ │
│ Client │ │ Your Auth UI │ │ Zuvo Auth │
│ App │ │ (Frontend) │ │ │
│ │ │ │ │ │
└──────┬──────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
│ 1. Generate PKCE params │
│ (code_verifier, code_challenge) │
│ │ │
│ 2. Redirect to /oauth/authorize with code_challenge │
├───────────────────────────────────────────────────────────────>│
│ │ │
│ │ 3. Validate params & redirect │
│ │ to authorization_path │
│ │<────────────────────────────────┤
│ │ │
│ │ 4. getAuthorizationDetails() │
│ ├────────────────────────────────>│
│ │ Return client info │
│ │<────────────────────────────────┤
│ │ │
│ │ 5. User login & consent │
│ │ │
│ │ 6. approveAuthorization() │
│ ├────────────────────────────────>│
│ │ Return redirect_url with code │
│ │<────────────────────────────────┤
│ │ │
│ 7. Redirect to client callback with code │
│<───────────────────────────────────────────────────────────────┤
│ │ │
│ 8. Exchange code for tokens (POST /oauth/token) │
│ with code_verifier │
├───────────────────────────────────────────────────────────────>│
│ │ │
│ 9. Return tokens (access, refresh, ID) │
│<───────────────────────────────────────────────────────────────┤
│ │ │
│ 10. Access resources with access_token │
│ │ │
│ 11. Refresh tokens (POST /oauth/token with refresh_token) │
├───────────────────────────────────────────────────────────────>│
│ │ │
│ 12. Return new tokens │
│<───────────────────────────────────────────────────────────────┤
│ │ │
Key points:
- Third-party client redirects user to Zuvo Auth's authorize endpoint (not directly to your UI)
- Zuvo Auth validates OAuth parameters and redirects to your authorization path
- Your frontend UI handles authentication and consent using
supabase-jsOAuth methods - Zuvo Auth handles all backend OAuth logic (code generation, token issuance)
Step 1: Generate PKCE parameters
Before initiating the flow, the client must generate PKCE parameters:
// Generate a random code verifier (43-128 characters)
function generateCodeVerifier() {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return base64URLEncode(array)
}
// Create code challenge from verifier
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const hash = await crypto.subtle.digest('SHA-256', data)
return base64URLEncode(new Uint8Array(hash))
}
function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Generate and store verifier (you'll need it later)
const codeVerifier = generateCodeVerifier()
sessionStorage.setItem('code_verifier', codeVerifier)
// Generate challenge to send in authorization request
const codeChallenge = await generateCodeChallenge(codeVerifier)
Step 2: Authorization request
The client redirects the user to your authorization endpoint with the following parameters:
https://<project-ref>.supabase.co/auth/v1/oauth/authorize?
response_type=code
&client_id=<client-id>
&redirect_uri=<configured-redirect-uri>
&state=<random-state>
&code_challenge=<code-challenge>
&code_challenge_method=S256
Required parameters
| Parameter | Description |
|---|---|
response_type | Must be code for authorization code flow |
client_id | The client ID from registration |
redirect_uri | Must exactly match a registered redirect URI |
code_challenge | The generated code challenge |
code_challenge_method | S256 (SHA-256, recommended) or plain |
Optional parameters
| Parameter | Description |
|---|---|
state | Random string to prevent CSRF attacks (highly recommended) |
scope | Space-separated list of scopes (e.g., openid email profile phone). Requested scopes will be included in the access token and control what information is returned by the UserInfo endpoint. Default scope when none provided is email. If the openid scope is requested, an ID token will be included in the response. |
nonce | Random string for replay attack protection. If provided, will be included in the ID token. |
Step 3: User authentication and consent
After receiving the authorization request, Zuvo Auth validates the OAuth parameters (client_id, redirect_uri, PKCE, etc.) and then redirects the user to your configured authorization path (e.g., https://example.com/oauth/consent?authorization_id=<id>).
The URL will contain an authorization_id query parameter that identifies this authorization request.
Your frontend application at the authorization path should:
- Extract authorization_id - Get the
authorization_idfrom the URL query parameters - Fetch authorization details - Call
supabase.auth.oauth.getAuthorizationDetails(authorization_id)to retrieve information about the OAuth client and request parameters - Check user authentication - Verify if the user is logged in; if not, redirect to your login page (preserving the full authorization path including the
authorization_id). After successful login, redirect the user back to the authorization path with the sameauthorization_idquery parameter - Display consent screen - Show the user information about the requesting client (name, redirect URI, scopes)
- Handle user decision - When the user approves or denies:
- Call
supabase.auth.oauth.approveAuthorization(authorization_id)to approve - Call
supabase.auth.oauth.denyAuthorization(authorization_id)to deny - Redirect user to the returned
redirect_urlURL
- Call
This is a frontend implementation using supabase-js. Zuvo Auth handles all the backend OAuth logic (generating authorization codes, validating requests, etc.) after you call the approve/deny methods.
See the Getting Started guide for complete implementation examples.
Step 4: Authorization code issued
If the user approves access, Zuvo Auth redirects back to the client's redirect URI with an authorization code:
https://client-app.com/callback?
code=<authorization-code>
&state=<state-from-request>
The authorization code is:
- Short-lived - Valid for 10 minutes
- Single-use - Can only be exchanged once
- Bound to PKCE - Can only be exchanged with the correct code verifier
If the user denies access, Zuvo Auth redirects with error information in query parameters:
https://client-app.com/callback?
error=access_denied
&error_description=The+user+denied+the+authorization+request
&state=<state-from-request>
The error parameters allow clients to display relevant error messages to users:
| Parameter | Description |
|---|---|
error | Error code (e.g., access_denied, invalid_request, server_error) |
error_description | Human-readable error description explaining what went wrong |
state | The state parameter from the original request (for CSRF protection) |
Step 5: Token exchange
The client exchanges the authorization code for tokens by making a POST request to the token endpoint. How the client authenticates depends on its token_endpoint_auth_method (set during client registration).
Public clients (token_endpoint_auth_method: none)
Public clients send only the client_id in the request body with no secret:
curl -X POST 'https://<project-ref>.supabase.co/auth/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'code=<authorization-code>' \
-d 'client_id=<client-id>' \
-d 'redirect_uri=<redirect-uri>' \
-d 'code_verifier=<code-verifier>'
Confidential clients (token_endpoint_auth_method: client_secret_basic)
This is the default for confidential clients. Credentials are sent via the Authorization header using HTTP Basic authentication (base64-encoded client_id:client_secret):
curl -X POST 'https://<project-ref>.supabase.co/auth/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u '<client-id>:<client-secret>' \
-d 'grant_type=authorization_code' \
-d 'code=<authorization-code>' \
-d 'redirect_uri=<redirect-uri>' \
-d 'code_verifier=<code-verifier>'
Confidential clients (token_endpoint_auth_method: client_secret_post)
Credentials are sent as form parameters in the request body:
curl -X POST 'https://<project-ref>.supabase.co/auth/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'code=<authorization-code>' \
-d 'client_id=<client-id>' \
-d 'client_secret=<client-secret>' \
-d 'redirect_uri=<redirect-uri>' \
-d 'code_verifier=<code-verifier>'
Example in JavaScript
// Retrieve the code verifier from storage
const codeVerifier = sessionStorage.getItem('code_verifier')
// --- Public clients (token_endpoint_auth_method: none) ---
const response = await fetch(`https://<project-ref>.supabase.co/auth/v1/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
client_id: '<client-id>',
redirect_uri: '<redirect-uri>',
code_verifier: codeVerifier,
}),
})
// --- Confidential clients (token_endpoint_auth_method: client_secret_basic) ---
const response = await fetch(`https://<project-ref>.supabase.co/auth/v1/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + btoa('<client-id>:<client-secret>'),
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: '<redirect-uri>',
code_verifier: codeVerifier,
}),
})
// --- Confidential clients (token_endpoint_auth_method: client_secret_post) ---
const response = await fetch(`https://<project-ref>.supabase.co/auth/v1/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
client_id: '<client-id>',
client_secret: '<client-secret>',
redirect_uri: '<redirect-uri>',
code_verifier: codeVerifier,
}),
})
const tokens = await response.json()
Step 6: Token response
On success, Zuvo Auth returns a JSON response with tokens:
{
"access_token": "eyJhbGc...",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "MXff...",
"scope": "openid email profile",
"id_token": "eyJhbGc..."
}
| Field | Description |
|---|---|
access_token | JWT access token for accessing resources |
token_type | Always bearer |
expires_in | Token lifetime in seconds (default: 3600) |
refresh_token | Token for obtaining new access tokens |
scope | Granted scopes from the authorization request |
id_token | OpenID Connect ID token (included only if openid scope was requested in the authorization request) |
Access token structure
Access tokens are JWTs containing standard Zuvo claims plus OAuth-specific claims:
{
"aud": "authenticated",
"exp": 1735819200,
"iat": 1735815600,
"iss": "https://<project-ref>.supabase.co/auth/v1",
"sub": "user-uuid",
"email": "user@example.com",
"phone": "",
"app_metadata": {
"provider": "email",
"providers": ["email"]
},
"user_metadata": {},
"role": "authenticated",
"aal": "aal1",
"amr": [
{
"method": "password",
"timestamp": 1735815600
}
],
"session_id": "session-uuid",
"client_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d"
}
OAuth-specific claims
| Claim | Description |
|---|---|
client_id | The OAuth client ID that obtained this token |
All other claims follow the standard Zuvo JWT structure.
Available scopes
The following scopes are currently supported:
| Scope | Description |
|---|---|
openid | Enables OpenID Connect. When requested, an ID token will be included in the response. |
email | Grants access to email and email_verified claims |
profile | Grants access to profile information (name, picture, etc.) |
phone | Grants access to phone_number and phone_number_verified claims |
Default scope: When no scope is specified in the authorization request, the default scope is email.
Scopes affect what information is included in ID tokens and returned by the UserInfo endpoint. All OAuth access tokens have full access to user data (same as regular session tokens), with the addition of the client_id claim. Use Row Level Security policies with the client_id claim to control which data each OAuth client can access.
Refresh token flow
Refresh tokens allow clients to obtain new access tokens without requiring the user to re-authenticate.
When to refresh
Clients should refresh access tokens when:
- The access token is expired (check the
expclaim) - The access token is about to expire (proactive refresh)
- An API call returns a 401 Unauthorized error
Refresh request
Make a POST request to the token endpoint with the refresh token. The client authenticates the same way as during the token exchange, based on its token_endpoint_auth_method.
Public clients (token_endpoint_auth_method: none)
curl -X POST 'https://<project-ref>.supabase.co/auth/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token' \
-d 'refresh_token=<refresh-token>' \
-d 'client_id=<client-id>'
Confidential clients (token_endpoint_auth_method: client_secret_basic)
curl -X POST 'https://<project-ref>.supabase.co/auth/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u '<client-id>:<client-secret>' \
-d 'grant_type=refresh_token' \
-d 'refresh_token=<refresh-token>'
Confidential clients (token_endpoint_auth_method: client_secret_post)
curl -X POST 'https://<project-ref>.supabase.co/auth/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token' \
-d 'refresh_token=<refresh-token>' \
-d 'client_id=<client-id>' \
-d 'client_secret=<client-secret>'
Example in JavaScript
// Public clients (token_endpoint_auth_method: none)
async function refreshAccessToken(refreshToken) {
const response = await fetch(`https://<project-ref>.supabase.co/auth/v1/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: '<client-id>',
}),
})
if (!response.ok) {
throw new Error('Failed to refresh token')
}
return await response.json()
}
// Confidential clients (token_endpoint_auth_method: client_secret_basic)
async function refreshAccessTokenConfidential(refreshToken) {
const response = await fetch(`https://<project-ref>.supabase.co/auth/v1/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + btoa('<client-id>:<client-secret>'),
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
})
if (!response.ok) {
throw new Error('Failed to refresh token')
}
return await response.json()
}
Refresh response
The response contains a new access token and optionally a new refresh token:
{
"access_token": "eyJhbGc...",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "v1.MXff...",
"scope": "openid email profile"
}
OpenID Connect (OIDC)
Zuvo Auth supports OpenID Connect, an identity layer on top of OAuth 2.1.
ID tokens
ID tokens are JWTs that contain user identity information. They are signed by Zuvo Auth and can be verified by clients.
The claims included in the ID token depend on the scopes requested during authorization. For example, requesting openid email profile will include email and profile-related claims, while requesting only openid email will include only email-related claims.
Example ID token
{
"iss": "https://<project-ref>.supabase.co/auth/v1",
"sub": "user-uuid",
"aud": "client-id",
"exp": 1735819200,
"iat": 1735815600,
"auth_time": 1735815600,
"nonce": "random-nonce-from-request",
"email": "user@example.com",
"email_verified": true,
"phone_number": "+1234567890",
"phone_number_verified": false,
"name": "John Doe",
"picture": "https://example.com/avatar.jpg"
}
Standard OIDC claims
| Claim | Description |
|---|---|
sub | Subject (user ID) |
nonce | The nonce value from the authorization request (if provided) |
email | User's email address |
email_verified | Whether the email is verified |
phone_number | User's phone number |
phone_number_verified | Whether the phone is verified |
name | User's full name |
picture | User's profile picture URL |
UserInfo endpoint
Clients can retrieve user information by calling the UserInfo endpoint with an access token:
curl 'https://<project-ref>.supabase.co/auth/v1/oauth/userinfo' \
-H 'Authorization: Bearer <access-token>'
The information returned depends on the scopes granted in the access token. For example:
With email scope:
{
"sub": "user-uuid",
"email": "user@example.com",
"email_verified": true
}
With email profile phone scopes:
{
"sub": "user-uuid",
"email": "user@example.com",
"email_verified": true,
"phone_number": "+1234567890",
"phone_number_verified": false,
"name": "John Doe",
"picture": "https://example.com/avatar.jpg"
}
OIDC discovery
Zuvo Auth exposes OpenID Connect and OAuth 2.1 discovery endpoints that describe its capabilities:
https://<project-ref>.supabase.co/auth/v1/.well-known/openid-configuration
https://<project-ref>.supabase.co/auth/v1/.well-known/oauth-authorization-server
These endpoints return metadata about:
- Available endpoints (authorization, token, userinfo, JWKS)
- Supported grant types and response types
- Supported scopes and claims
- Token signing algorithms
This enables automatic integration with OIDC-compliant libraries and tools.
Token validation
Third-party clients should validate access tokens to ensure they're authentic and not tampered with.
JWKS endpoint
Zuvo Auth exposes a JSON Web Key Set (JWKS) endpoint containing public keys for token verification:
https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
Example response:
{
"keys": [
{
"kty": "RSA",
"kid": "key-id",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}
Validating tokens
Use a JWT library to verify tokens:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json')
)
async function verifyAccessToken(token) {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://<project-ref>.supabase.co/auth/v1',
audience: 'authenticated',
})
return payload
} catch (error) {
console.error('Token verification failed:', error)
return null
}
}
from jose import jwt
from jose.backends import RSAKey
import requests
# Fetch JWKS
jwks = requests.get('https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json').json()
def verify_access_token(token):
try:
payload = jwt.decode(
token,
jwks,
algorithms=['RS256'],
issuer='https://<project-ref>.supabase.co/auth/v1',
audience='authenticated'
)
return payload
except jwt.JWTError as e:
print(f'Token verification failed: {e}')
return None
package main
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
)
func verifyAccessToken(ctx context.Context, token string) (*oidc.IDToken, error) {
provider, err := oidc.NewProvider(
ctx,
"https://<project-ref>.supabase.co/auth/v1",
)
if err != nil {
return nil, err
}
verifier := provider.Verifier(&oidc.Config{
ClientID: "authenticated",
})
return verifier.Verify(ctx, token)
}
What to validate
Always verify:
- Signature - Token is signed by Zuvo Auth
- Issuer (
iss) - Matches your project URL - Audience (
aud) - Isauthenticated - Expiration (
exp) - Token is not expired - Client ID (
client_id) - Matches your client (if applicable)
Managing user grants
Users can view and manage the OAuth applications they've authorized to access their account. This is important for transparency and security, allowing users to audit and revoke access when needed.
Viewing authorized applications
Users can retrieve a list of all OAuth clients they've authorized:
const { data: grants, error } = await supabase.auth.oauth.getUserGrants()
if (error) {
console.error('Error fetching grants:', error)
} else {
console.log('Authorized applications:', grants)
}
The response includes details about each authorized OAuth client:
[
{
"id": "grant-uuid",
"client_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"client_name": "My Third-Party App",
"scopes": ["email", "profile"],
"created_at": "2025-01-15T10:30:00.000Z",
"updated_at": "2025-01-15T10:30:00.000Z"
}
]
Revoking access
Users can revoke access for a specific OAuth client at any time. When access is revoked, all active sessions and refresh tokens for that client are immediately invalidated:
const { error } = await supabase.auth.oauth.revokeGrant(clientId)
if (error) {
console.error('Error revoking access:', error)
} else {
console.log('Access revoked successfully')
}
After revoking access:
- All refresh tokens for that client are deleted
- The user will need to re-authorize the application to grant access again
For complete API reference, see the OAuth methods in supabase-js.
Next steps
- Implement MCP authentication - Enable AI agent authentication
- Secure with RLS - Control data access for OAuth clients
- Learn about JWTs - Understand Zuvo token structure