> ## Documentation Index
> Fetch the complete documentation index at: https://api.fanvue.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Implementation Guide

> Implement the Fanvue OAuth 2.0 flow with PKCE: authorization, token exchange, and refresh, with code.

This is the comprehensive reference for implementing OAuth 2.0 with the Fanvue API. For a fast getting-started path, see the [OAuth 2.0 Tutorial](/docs/introduction/quick-start).

## OAuth 2.0 flow

The flow uses the authorization code grant with PKCE. Your app generates a code verifier and challenge, sends the user to Fanvue to authorize, receives a one-time code on the callback, then exchanges that code (plus the verifier) for access and refresh tokens.

```mermaid theme={null}
sequenceDiagram
    participant User
    participant YourApp as Your Application
    participant Fanvue as Fanvue Authorization Server
    participant API as Fanvue API

    User->>YourApp: 1. Initiates login/connection
    YourApp->>YourApp: 2. Generates code_verifier & code_challenge
    YourApp->>User: 3. Redirects to Fanvue authorization URL (with code_challenge)
    User->>Fanvue: 4. Logs in and grants permissions
    Fanvue->>YourApp: 5. Redirects back with authorization code
    YourApp->>Fanvue: 6. Exchanges code for access token (with code_verifier)
    Fanvue->>Fanvue: 7. Verifies code_verifier matches code_challenge
    Fanvue->>YourApp: 8. Returns access token & refresh token
    YourApp->>API: 9. Makes API calls with access token
    API->>YourApp: 10. Returns requested data
```

## PKCE (Proof Key for Code Exchange)

PKCE is required for all OAuth 2.0 flows. It prevents authorization code interception attacks: even if an attacker steals the authorization code, they cannot exchange it for tokens without your original `code_verifier`.

How it works:

1. **Generate a code verifier**: a cryptographically random string (43 to 128 characters).
2. **Create a code challenge**: SHA-256 hash the verifier, then Base64URL-encode it.
3. **Send the challenge**: include `code_challenge` (and `code_challenge_method=S256`) in the authorization request.
4. **Store the verifier securely** until the token exchange (see below).
5. **Send the verifier**: include `code_verifier` when exchanging the code.
6. **Server verification**: Fanvue checks that `SHA256(code_verifier)` matches the original `code_challenge`.

### Storing the code verifier

Store the `code_verifier` between the authorization request and the token exchange.

* **Server-side session storage** (preferred): Redis, a database, or in-memory session.
* **Secure HTTP-only cookies**: use the `Secure`, `HttpOnly`, and `SameSite=Lax` flags.
* **Encrypted client storage**: only if server-side storage is not possible.

<Warning>
  Never store the verifier in local/session storage (vulnerable to XSS), in URL parameters, or in client-side JavaScript variables that persist across page loads.
</Warning>

### Generating verifier and challenge

To support any language: create 32 random bytes for the verifier and Base64URL-encode them (standard Base64, with `+` to `-`, `/` to `_`, and trailing `=` padding removed), giving at least 43 characters. The challenge is the SHA-256 hash of the verifier, Base64URL-encoded the same way, sent with `code_challenge_method=S256`.

<Tabs>
  <Tab title="JavaScript/Node.js">
    ```javascript theme={null}
    import { randomBytes, createHash } from 'crypto';

    // Helper for Base64URL encoding
    function base64URLEncode(buffer) {
      return buffer
        .toString('base64')
        .replace(/=/g, '')
        .replace(/\+/g, '-')
        .replace(/\//g, '_');
    }

    // Generate code verifier (43-128 characters)
    function generateCodeVerifier() {
      return base64URLEncode(randomBytes(32));
    }

    // Generate code challenge from verifier
    function generateCodeChallenge(verifier) {
      return base64URLEncode(
        createHash('sha256').update(verifier).digest()
      );
    }

    const codeVerifier = generateCodeVerifier();
    const codeChallenge = generateCodeChallenge(codeVerifier);
    // Store codeVerifier in session/cookie; send codeChallenge with the auth request
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import secrets
    import hashlib
    import base64

    def generate_code_verifier():
        """Generate a code verifier (43-128 characters)"""
        code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')
        return code_verifier.rstrip('=')

    def generate_code_challenge(verifier):
        """Generate code challenge from verifier using SHA-256"""
        digest = hashlib.sha256(verifier.encode('utf-8')).digest()
        challenge = base64.urlsafe_b64encode(digest).decode('utf-8')
        return challenge.rstrip('=')

    code_verifier = generate_code_verifier()
    code_challenge = generate_code_challenge(code_verifier)
    # Store code_verifier in session; send code_challenge with the auth request
    ```
  </Tab>
</Tabs>

## Authorization URL

Redirect the user to the authorization endpoint with the parameters below.

<Info>
  Always include these default scopes:

  * `openid` - Required for OpenID Connect; enables ID token generation and access to user identity.
  * `offline_access` - Provides refresh tokens so your app can obtain new access tokens without re-authentication.
  * `offline` - Enables long-term access for background operations.
</Info>

**Required parameters:**

* `client_id` - Your OAuth application's client ID.
* `redirect_uri` - Where users are redirected after authorization (must match your app configuration).
* `response_type=code` - Indicates the authorization code flow.
* `scope` - Space-separated list of permissions (URL encoded with `+`).
* `state` - Random string to prevent CSRF attacks (verify it matches on callback).
* `code_challenge` - \[PKCE] The Base64URL-encoded SHA-256 hash of your `code_verifier`.
* `code_challenge_method=S256` - \[PKCE] Indicates the SHA-256 hashing method.

```
https://auth.fanvue.com/oauth2/auth?
  client_id=YOUR_CLIENT_ID&
  redirect_uri=YOUR_REDIRECT_URI&
  response_type=code&
  scope=openid+offline_access+offline+read:self+read:chat&
  state=RANDOM_STRING&
  code_challenge=CODE_CHALLENGE&
  code_challenge_method=S256
```

## Token exchange

After receiving the authorization code on your callback, exchange it for access and refresh tokens.

### How to authenticate the token request

Fanvue registers confidential clients with the `client_secret_basic` authentication method. Send your `client_id` and `client_secret` in an HTTP **Basic Auth header**, not in the request body:

```
Authorization: Basic base64("client_id:client_secret")
```

<Warning>
  Do **not** put `client_id` and `client_secret` in the POST body. That is the `client_secret_post` method, which Fanvue clients are not registered for, so the token endpoint rejects the request with `invalid_client`. Several common libraries default to `client_secret_post` — set them to `client_secret_basic`:

  * **openid-client (Node)**: `token_endpoint_auth_method: 'client_secret_basic'`
  * **Python `requests`**: pass `auth=HTTPBasicAuth(client_id, client_secret)` and drop the credentials from `data`
  * **Postman / manual**: use the Authorization tab's Basic Auth fields, not body params
</Warning>

**Public (PKCE-only) clients:** if your app has no `client_secret` (registered with auth method `none`), do not send a secret at all. The `code_verifier` authenticates the exchange on its own. Confirm your app's type in the Builder area before choosing an auth method.

**Required parameters** (in the request body):

* `grant_type=authorization_code` - Indicates you are exchanging an authorization code.
* `code` - The authorization code received from the callback.
* `redirect_uri` - Must match the redirect URI used in the authorization request.
* `code_verifier` - \[PKCE] The original `code_verifier` you generated (NOT the challenge).

<Warning>
  Send the `code_verifier`, not the `code_challenge`. The server hashes the verifier and compares it to the challenge you sent earlier.
</Warning>

```http theme={null}
POST https://auth.fanvue.com/oauth2/token
Authorization: Basic base64(YOUR_CLIENT_ID:YOUR_CLIENT_SECRET)
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=YOUR_REDIRECT_URI&
code_verifier=CODE_VERIFIER
```

**Response:**

```json theme={null}
{
  "access_token": "eyJhbGc...",
  "refresh_token": "eyJhbGc...",
  "id_token": "eyJhbGc...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid offline_access offline read:self read:chat"
}
```

## Handling the callback

Fanvue redirects the user back to your `redirect_uri` with the authorization code and state:

```
https://your-app.com/callback?code=AUTHORIZATION_CODE&state=STATE_VALUE
```

On the callback you should:

1. **Validate `state`** against the value you stored, to prevent CSRF.
2. **Retrieve the stored `code_verifier`** from session or cookies.
3. **Exchange the code** for tokens (see above), handling failures gracefully.
4. **Clean up** the temporary `state` and `code_verifier`.
5. **Store tokens securely**, encrypted at rest (a database is preferred over a plain session).

The example below uses Express.js. The same two routes (initiate, then callback) apply to any framework: persist `code_verifier` and `state` when you build the authorization URL, then verify and exchange them on the callback.

<Tabs>
  <Tab title="Express.js">
    ```javascript theme={null}
    import express from 'express';
    import { randomBytes } from 'crypto';

    const app = express();

    // Step 1: Initiate OAuth flow
    app.get('/auth/fanvue', (req, res) => {
      const codeVerifier = generateCodeVerifier();
      const codeChallenge = generateCodeChallenge(codeVerifier);
      const state = randomBytes(32).toString('hex');

      // Store code_verifier and state in session
      req.session.codeVerifier = codeVerifier;
      req.session.oauthState = state;

      const authUrl = new URL('https://auth.fanvue.com/oauth2/auth');
      authUrl.searchParams.append('client_id', process.env.CLIENT_ID);
      authUrl.searchParams.append('redirect_uri', process.env.REDIRECT_URI);
      authUrl.searchParams.append('response_type', 'code');
      authUrl.searchParams.append('scope', 'openid offline_access offline read:self');
      authUrl.searchParams.append('state', state);
      authUrl.searchParams.append('code_challenge', codeChallenge);
      authUrl.searchParams.append('code_challenge_method', 'S256');

      res.redirect(authUrl.toString());
    });

    // Step 2: Handle OAuth callback
    app.get('/callback', async (req, res) => {
      const { code, state } = req.query;

      // Validate state parameter (CSRF protection)
      if (!state || state !== req.session.oauthState) {
        return res.status(400).send('Invalid state parameter');
      }

      // Retrieve the stored code_verifier
      const codeVerifier = req.session.codeVerifier;
      if (!codeVerifier) {
        return res.status(400).send('Code verifier not found in session');
      }

      // Clear session data
      delete req.session.oauthState;
      delete req.session.codeVerifier;

      try {
        // Authenticate the client with HTTP Basic Auth (client_secret_basic)
        const basicAuth = Buffer.from(
          `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
        ).toString('base64');

        // Exchange authorization code for tokens
        const tokenResponse = await fetch('https://auth.fanvue.com/oauth2/token', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Authorization': `Basic ${basicAuth}`,
          },
          body: new URLSearchParams({
            grant_type: 'authorization_code',
            code: code,
            redirect_uri: process.env.REDIRECT_URI,
            code_verifier: codeVerifier,
          }),
        });

        if (!tokenResponse.ok) {
          const error = await tokenResponse.json();
          throw new Error(`Token exchange failed: ${error.error_description}`);
        }

        const tokens = await tokenResponse.json();

        // Store tokens securely (e.g., encrypted in database)
        req.session.accessToken = tokens.access_token;
        req.session.refreshToken = tokens.refresh_token;
        req.session.tokenExpiry = Date.now() + (tokens.expires_in * 1000);

        res.redirect('/dashboard');
      } catch (error) {
        console.error('OAuth callback error:', error);
        res.status(500).send('Authentication failed');
      }
    });
    ```
  </Tab>
</Tabs>

## Token refresh

Access tokens are short-lived (typically 1 hour). Use the refresh token to obtain new access tokens without sending the user back through authorization.

Authenticate the refresh request the same way as the token exchange: `client_secret_basic` via the Basic Auth header, with only `grant_type` and `refresh_token` in the body.

```http theme={null}
POST https://auth.fanvue.com/oauth2/token
Authorization: Basic base64(YOUR_CLIENT_ID:YOUR_CLIENT_SECRET)
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&
refresh_token=YOUR_REFRESH_TOKEN
```

**Response:**

```json theme={null}
{
  "access_token": "eyJhbGc...",
  "refresh_token": "eyJhbGc...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid offline_access offline read:self read:chat"
}
```

The refresh response may include a new refresh token, so always persist both values. Refresh tokens rotate and are single-use, but a **30-second grace period** makes reuse within that window idempotent: presenting the same refresh token again within 30 seconds of rotating it replays the original response (same access and refresh token). Reuse after the window still triggers reuse detection and invalidates the whole refresh chain — see [Refresh tokens rotate and are single-use](/docs/authentication/overview#refresh-tokens-rotate-and-are-single-use). The client below refreshes proactively (with a buffer before expiry), serialises concurrent refreshes, and retries once on a `401`.

<Tabs>
  <Tab title="JavaScript/Node.js">
    ```javascript theme={null}
    class FanvueClient {
      constructor(accessToken, refreshToken, expiresAt) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
        this.tokenExpiresAt = expiresAt;
        this.refreshPromise = null;
      }

      async ensureValidToken() {
        // Refresh if expired or about to expire (within 5 minutes)
        const now = Date.now();
        const bufferTime = 5 * 60 * 1000; // 5 minutes

        if (now + bufferTime >= this.tokenExpiresAt) {
          // If already refreshing, wait for that promise
          if (this.refreshPromise) {
            return this.refreshPromise;
          }

          this.refreshPromise = this.refreshAccessToken();

          try {
            await this.refreshPromise;
          } finally {
            this.refreshPromise = null;
          }
        }

        return this.accessToken;
      }

      async refreshAccessToken() {
        const basicAuth = Buffer.from(
          `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
        ).toString('base64');

        const response = await fetch('https://auth.fanvue.com/oauth2/token', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Authorization': `Basic ${basicAuth}`,
          },
          body: new URLSearchParams({
            grant_type: 'refresh_token',
            refresh_token: this.refreshToken,
          }),
        });

        if (!response.ok) {
          const error = await response.json();
          throw new Error(`Token refresh failed: ${error.error_description}`);
        }

        const tokens = await response.json();

        // Update both tokens; the response may include a new refresh token
        this.accessToken = tokens.access_token;
        this.refreshToken = tokens.refresh_token;
        this.tokenExpiresAt = Date.now() + (tokens.expires_in * 1000);

        await this.saveTokens(); // persist to your storage
        return this.accessToken;
      }

      async makeApiRequest(endpoint, options = {}) {
        const token = await this.ensureValidToken();

        const response = await fetch(`https://api.fanvue.com${endpoint}`, {
          ...options,
          headers: { ...options.headers, 'Authorization': `Bearer ${token}` },
        });

        // If the token expired mid-request, refresh and retry once
        if (response.status === 401) {
          await this.refreshAccessToken();
          return fetch(`https://api.fanvue.com${endpoint}`, {
            ...options,
            headers: { ...options.headers, 'Authorization': `Bearer ${this.accessToken}` },
          });
        }

        return response;
      }

      async saveTokens() {
        // Implement your storage logic (database, encrypted session, etc.)
      }
    }

    // Usage
    const client = new FanvueClient(accessToken, refreshToken, expiresAt);
    const response = await client.makeApiRequest('/users/me');
    const user = await response.json();
    ```
  </Tab>
</Tabs>

**Refresh best practices:** refresh proactively before expiry (5 to 10 minute buffer), use a lock or shared promise to avoid concurrent refreshes (the 30-second grace period absorbs the occasional retry or race, but serialising is still the right default), retry a `401` once after refreshing, store refresh tokens encrypted, always update both tokens from the response, and redirect the user to re-authenticate if a refresh fails.

## Managing your OAuth client secret

When you create an app in the Builder area, Fanvue generates a **Client ID** and a **Client Secret**. The Client ID is a public identifier; the Client Secret authenticates your backend to Fanvue's token endpoint. Mishandling it is the single most common cause of serious OAuth incidents.

### Save the secret immediately

The Client Secret is shown **only once**, when the app is created. Fanvue does not store it in a retrievable form, so if you close the creation screen without copying it, your only option is to regenerate.

* Copy the secret into your secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password, etc.) **before** navigating away from the creation page.
* In the Fanvue App Starter and similar local setups, put the secret into `.env.local` as `OAUTH_CLIENT_SECRET`. Never commit `.env` files to version control.

### Store it only on your server

The Client Secret is a server-side credential. Never:

* Commit it to a git repository (public or private).
* Ship it in a browser bundle, mobile app binary, or any client-side artefact.
* Log it in application logs, analytics events, or error reports.
* Paste it into shared chat channels or tickets.

All OAuth token exchanges must happen from your backend, where the secret is read from an environment variable or secrets manager at runtime.

### Regenerating the secret is a breaking change

You can regenerate the Client Secret at any time from the Builder area. Regeneration:

* **Immediately invalidates the previous secret.** Any running service that still holds the old value starts receiving `invalid_client` errors from the token endpoint.
* **Breaks every installation that depends on your backend** until each instance is updated with the new secret.
* **Does not affect the Client ID or existing access/refresh tokens** directly, but your backend cannot refresh expired tokens until it has the new secret.

<Warning>
  ##### Plan secret rotation before you regenerate

  Regeneration is not reversible, and during the rollout window the old secret is dead and the new secret is not yet everywhere. For zero-downtime rotation:

  1. Generate the new secret in the Builder area and copy it into your secrets manager alongside (or replacing) the old one.
  2. Roll out the updated secret to every instance of your service.
  3. Verify that new token exchanges succeed with the new secret before considering the rotation complete.
</Warning>

If a secret is ever exposed (committed to a repo by mistake, leaked in a log, or included in a shared screenshot), treat it as compromised and regenerate immediately, even if the exposure was brief. The cost of rotation (a short rollout) is far lower than the cost of a leaked credential being abused.

See also: [Listing Requirements, Security and authentication](/docs/app-store/listing-requirements) for the policy baseline your app must meet, and the [App Store Journey](/docs/app-store/introduction#the-app-store-journey) for where credential generation fits in the overall flow.

## Error handling

The token and authorization endpoints return standard OAuth errors as JSON with `error` and `error_description` fields. For the full catalogue of HTTP status codes, body shapes, rate-limit headers, and retry guidance shared across the Fanvue API, see [API Conventions](/docs/api-reference/overview). In your own code, separate OAuth errors from network errors, retry only on server errors (`5xx`) and rate limits (`429`) with exponential backoff (never on `4xx`), and surface friendly messages to users rather than raw error codes.

Common OAuth errors and what they mean:

| Error                            | Likely cause and fix                                                                                                                                                                                                                                                                             |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `invalid_client`                 | Client authentication failed. Most often the credentials are in the POST body (`client_secret_post`) instead of the Basic Auth header — send them as `client_secret_basic` (see [Token exchange](#token-exchange)). Also verify your `client_id` and `client_secret` and that the app is active. |
| `invalid_request` (redirect URI) | The `redirect_uri` does not match the registered callback URL. Check it matches exactly, including protocol and encoding.                                                                                                                                                                        |
| `invalid_scope`                  | A requested scope is invalid or unavailable. Ensure scopes are valid, space-separated, and URL-encoded with `+`.                                                                                                                                                                                 |
| `invalid_grant` (expired)        | The authorization code or token has expired. Refresh the token, or have the user re-authenticate.                                                                                                                                                                                                |
| `invalid_grant` (code verifier)  | PKCE validation failed. Send the original `code_verifier` (not the challenge) and confirm it matches the stored value.                                                                                                                                                                           |

## Best practices

### Security

* **PKCE is mandatory** for all OAuth flows.
* **Code verifier**: 43 to 128 characters, cryptographically secure, stored server-side (session/Redis) or in HTTP-only cookies. Never expose it in URLs, local storage, or client-side JavaScript.
* Always use HTTPS in production.
* Store tokens encrypted at rest, and implement proper token refresh.
* Use the `state` parameter to prevent CSRF (minimum 32 random characters).
* Validate that redirect URIs match exactly what is configured in your app.
* Keep your Client Secret server-side and never expose it in client-side code.

### User experience

* Clearly explain what permissions your app needs.
* Handle authorization errors gracefully.
* Provide a way for users to disconnect your app.
* Respect rate limits and user privacy.

### Token management

* Access tokens are short-lived (typically 1 hour); use refresh tokens to renew them.
* Refresh automatically before expiration, and handle expiry gracefully.
* Revoke tokens when users disconnect.

## Support

* **Quick Start**: see the [OAuth App Quick Start](/docs/introduction/quick-start) to get started.
* **Implementation**: follow our [Quickstart Guide](/docs/introduction/quick-start) for a complete Next.js example.
* **API Reference**: check our [API documentation](https://api.fanvue.com/docs).
* **App Management**: visit the [developer portal](https://fanvue.com/developers/apps).
* **Template**: review the [starter template](https://github.com/fanvue/fanvue-app-starter) on GitHub.
