Skip to main content

OAuth 2.1 Quickstart

Alpha Feature: OAuth 2.1 integration is currently in alpha testing. It’s functional and secure, but API/UI may change. Not recommended for critical production integrations yet. Learn more about alpha status
Build integrations with “Sign in with AI Stats” using OAuth 2.1 with PKCE. This guide walks you through implementing the authorization code flow.

Overview

OAuth 2.1 allows your application to:
  • Access the AI Stats API gateway on behalf of users
  • Make requests using user’s team credits and limits
  • Let users authorize/revoke access at any time
Time to complete: 5-10 minutes

Step 1: Register Your OAuth App

First, create an OAuth application in the AI Stats dashboard.

Via Dashboard

  1. Go to Settings → OAuth Apps
  2. Click “Create OAuth App”
  3. Fill in the details:
    • Name: Your application name
    • Redirect URIs: http://localhost:3000/auth/callback (for dev)
    • Homepage URL: Your website (optional)
  4. Click “Create App”
Important: Save your client_id and client_secret immediately. The secret is only shown once!

Via API

curl -X POST https://api.phaseo.app/v1/oauth-clients \
  -H "Authorization: Bearer aistats_v1_sk_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My App",
    "redirect_uris": ["http://localhost:3000/auth/callback"],
    "homepage_url": "https://myapp.com"
  }'

Step 2: Implement PKCE

OAuth 2.1 requires PKCE (Proof Key for Code Exchange) for security.

Generate Code Verifier & Challenge

import crypto from 'crypto';

// Generate random verifier
function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}

// Create SHA256 challenge
function generateCodeChallenge(verifier: string) {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
}

// Store these for the session
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);

Browser-Compatible Version

// Generate verifier
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

// Create challenge
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(array) {
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

Step 3: Redirect User to Authorization

Build the authorization URL and redirect the user:
const authUrl = new URL('https://api.phaseo.app/oauth/consent');
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/auth/callback');
authUrl.searchParams.set('scope', 'openid email gateway:access');
authUrl.searchParams.set('state', randomState); // CSRF protection
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');

// Redirect user
window.location.href = authUrl.toString();

Available Scopes

ScopeDescription
openidRequired for OAuth 2.1
emailAccess user’s email address
profileAccess user’s profile information
gateway:accessMake API requests on user’s behalf

Step 4: Handle the Callback

After the user authorizes, they’ll be redirected back with a code:
// GET /auth/callback?code=auth_123&state=xyz

import { NextRequest } from 'next/server';

export async function GET(req: NextRequest) {
  const searchParams = req.nextUrl.searchParams;
  const code = searchParams.get('code');
  const state = searchParams.get('state');

  // Verify state matches (CSRF protection)
  if (state !== storedState) {
    return new Response('Invalid state', { status: 400 });
  }

  // Exchange code for token (next step)
  const tokens = await exchangeCodeForToken(code);

  return new Response('Authorized!');
}

Step 5: Exchange Code for Tokens

Exchange the authorization code for access and refresh tokens:
async function exchangeCodeForToken(code: string) {
  const response = await fetch(
    'https://YOUR_PROJECT.supabase.co/auth/v1/oauth/token',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code: code,
        code_verifier: codeVerifier, // From step 2
        redirect_uri: 'http://localhost:3000/auth/callback',
        client_id: 'YOUR_CLIENT_ID',
        client_secret: 'YOUR_CLIENT_SECRET',
      }),
    }
  );

  const tokens = await response.json();
  return tokens;
  /*
  {
    "access_token": "eyJhbGc...",
    "refresh_token": "...",
    "expires_in": 3600,
    "token_type": "Bearer"
  }
  */
}
Important: Keep the refresh_token secure. Never expose it to the browser.

Step 6: Make API Requests

Use the access token to make requests to the AI Stats API:
const response = await fetch(
  'https://api.phaseo.app/v1/responses',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'openai/gpt-5-nano-2025-08-07',
      input: [
        {
          role: 'user',
          content: [{ type: 'output_text', text: 'Hello, world!' }],
        },
      ],
    }),
  }
);

const completion = await response.json();
console.log(completion.output?.[0]?.content?.[0]?.text);

Step 7: Refresh Access Tokens

Access tokens expire after 1 hour. Use the refresh token to get a new one:
async function refreshAccessToken(refreshToken: string) {
  const response = await fetch(
    'https://YOUR_PROJECT.supabase.co/auth/v1/oauth/token',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: 'YOUR_CLIENT_ID',
        client_secret: 'YOUR_CLIENT_SECRET',
      }),
    }
  );

  const tokens = await response.json();
  return tokens;
}

Complete Example (Next.js)

Here’s a complete working example:
// app/api/auth/authorize/route.ts
import { NextRequest } from 'next/server';
import { cookies } from 'next/headers';
import crypto from 'crypto';

export async function GET(req: NextRequest) {
  // Generate PKCE
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  const state = crypto.randomBytes(16).toString('hex');

  // Store in secure cookie
  cookies().set('oauth_state', state, { httpOnly: true });
  cookies().set('code_verifier', verifier, { httpOnly: true });

  // Build authorization URL
  const authUrl = new URL('https://api.phaseo.app/oauth/consent');
  authUrl.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID!);
  authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/auth/callback');
  authUrl.searchParams.set('scope', 'openid email gateway:access');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', challenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  return Response.redirect(authUrl.toString());
}

// app/api/auth/callback/route.ts
export async function GET(req: NextRequest) {
  const searchParams = req.nextUrl.searchParams;
  const code = searchParams.get('code');
  const state = searchParams.get('state');

  const cookieStore = cookies();
  const storedState = cookieStore.get('oauth_state')?.value;
  const verifier = cookieStore.get('code_verifier')?.value;

  // Verify state
  if (state !== storedState) {
    return new Response('Invalid state', { status: 400 });
  }

  // Exchange code for token
  const tokenResponse = await fetch(
    `${process.env.SUPABASE_URL}/auth/v1/oauth/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code,
        code_verifier: verifier,
        redirect_uri: 'http://localhost:3000/auth/callback',
        client_id: process.env.OAUTH_CLIENT_ID,
        client_secret: process.env.OAUTH_CLIENT_SECRET,
      }),
    }
  );

  const tokens = await tokenResponse.json();

  // Store tokens securely (e.g., encrypted session)
  cookieStore.set('access_token', tokens.access_token, {
    httpOnly: true,
    secure: true,
    maxAge: tokens.expires_in,
  });

  // Clear PKCE cookies
  cookieStore.delete('oauth_state');
  cookieStore.delete('code_verifier');

  return Response.redirect('/dashboard');
}

Testing Your Integration

1. Test Authorization Flow

# Start your dev server
npm run dev

# Visit authorization endpoint
open http://localhost:3000/api/auth/authorize

2. Test API Request

curl -X POST https://api.phaseo.app/v1/responses \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-5-nano-2025-08-07",
    "input": [
      {
        "role": "user",
        "content": [{"type": "output_text", "text": "Hello!"}]
      }
    ]
  }'

Security Best Practices

✅ Do’s

  • Use PKCE - Always required for OAuth 2.1
  • Validate state - Prevent CSRF attacks
  • Use HTTPS - Required in production (localhost HTTP is OK for dev)
  • Store secrets server-side - Never expose client_secret in browser
  • Refresh tokens proactively - Don’t wait for 401 errors
  • Handle revocation gracefully - Check for 401 and prompt re-authorization

❌ Don’ts

  • Don’t use implicit flow - Not supported (OAuth 2.1 requirement)
  • Don’t store tokens in localStorage - Use httpOnly cookies
  • Don’t log tokens - Treat them like passwords
  • Don’t share client_secret - Keep it secret, keep it safe
  • Don’t skip state validation - Critical for CSRF protection

Troubleshooting

”Invalid redirect_uri”

Cause: The redirect URI doesn’t match what’s registered in your OAuth app. Fix: Ensure the URI matches exactly (including protocol, port, and path).

”Invalid code_challenge”

Cause: PKCE verification failed. Fix: Ensure you’re using the same code_verifier from step 2 when exchanging the code.

”Authorization has been revoked”

Cause: User revoked access from their settings. Fix: Redirect user to re-authorize your app.

”Token has expired”

Cause: Access token expired (1 hour lifetime). Fix: Use the refresh token to get a new access token.

Next Steps


Need Help?

Last modified on February 17, 2026