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.
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.
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:
- Generate a code verifier: a cryptographically random string (43 to 128 characters).
- Create a code challenge: SHA-256 hash the verifier, then Base64URL-encode it.
- Send the challenge: include
code_challenge (and code_challenge_method=S256) in the authorization request.
- Store the verifier securely until the token exchange (see below).
- Send the verifier: include
code_verifier when exchanging the code.
- 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.
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.
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.
JavaScript/Node.js
Python
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
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
Authorization URL
Redirect the user to the authorization endpoint with the parameters below.
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.
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.
Required parameters:
grant_type=authorization_code - Indicates you are exchanging an authorization code.
client_id - Your OAuth application’s client ID.
client_secret - Your OAuth application’s client secret.
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).
Send the code_verifier, not the code_challenge. The server hashes the verifier and compares it to the challenge you sent earlier.
POST https://auth.fanvue.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
code=AUTHORIZATION_CODE&
redirect_uri=YOUR_REDIRECT_URI&
code_verifier=CODE_VERIFIER
Response:
{
"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:
- Validate
state against the value you stored, to prevent CSRF.
- Retrieve the stored
code_verifier from session or cookies.
- Exchange the code for tokens (see above), handling failures gracefully.
- Clean up the temporary
state and code_verifier.
- 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.
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 {
// 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',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
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');
}
});
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.
POST https://auth.fanvue.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
refresh_token=YOUR_REFRESH_TOKEN
Response:
{
"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. The client below refreshes proactively (with a buffer before expiry), serialises concurrent refreshes, and retries once on a 401.
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 response = await fetch('https://auth.fanvue.com/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
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();
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.
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.
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:
- Generate the new secret in the Builder area and copy it into your secrets manager alongside (or replacing) the old one.
- Roll out the updated secret to every instance of your service.
- Verify that new token exchanges succeed with the new secret before considering the rotation complete.
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 for the policy baseline your app must meet, and 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. 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. 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