Skip to main content
Flynet’s OAuth implementation follows the Token-Mediating Backend variant of OAuth 2.0 + PKCE. Your backend holds the client_secret and refresh token. The browser only sees a short-lived access token.

Prerequisites

After your app is approved, Blackbird sends you:
  • client_id - embed in your frontend
  • client_secret - backend-only
  • A registered redirect URI - your callback URL
  • Your allowed scopes - e.g. read:profile, read:checkins, read:wallets
Chef’s warning - This flow assumes you have a backend that can hold client_secret and store the refresh token. Pure single-page apps without a backend need a different OAuth variant. Contact support if that is your situation.

Environment

For staging:
AUTH_BASE_URL=https://api.staging.blackbird.xyz/oauth
API_BASE_URL=https://api.staging.blackbird.xyz/flynet/v1

The flow

1. Browser -> /oauth/authorize
2. Callback -> /api/oauth/exchange -> /oauth/token
3. API calls with Authorization: Bearer <access_token>
4. Refresh -> /api/oauth/refresh -> /oauth/token
Steps 2 and 4 hit Blackbird’s /oauth/token from your backend. Steps 1 and 3 happen in the browser.

Step 1 - Initiate authorization

Generate PKCE parameters, store them in sessionStorage, and redirect the browser to /oauth/authorize.
async function login() {
  const codeVerifier = base64UrlEncode(randomBytes(48));
  const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
  const state = base64UrlEncode(randomBytes(24));

  sessionStorage.setItem('oauth_pending', JSON.stringify({
    state,
    code_verifier: codeVerifier,
    created_at: Date.now(),
  }));

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'read:profile read:checkins',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `${AUTH_BASE_URL}/authorize?${params}`;
}
Tasting note - Scope names are exact-match. read:profiles returns invalid_request. Use read:profile. Request only the scopes your app needs: a token is granted exactly the scopes you ask for, and member routes outside those scopes return 403 insufficient_scope.
Tasting note - /oauth/authorize requires PKCE: the code_challenge + code_challenge_method=S256 parameters above are mandatory, and the matching code_verifier is required on token exchange. A bare authorize URL without them is rejected. The authorize request 302-redirects the browser to the consent host passport.staging.flynet.org: the auth tenant is a separate host from the API gateway, the same split you see in the token’s iss claim.

Step 2 - Callback and token exchange

On the callback URL, your frontend reads the code and forwards it to your backend with the code_verifier. Your backend calls /oauth/token with client_secret, sets the refresh token in an HttpOnly cookie, and returns the access token to the frontend.
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');

  const pending = JSON.parse(sessionStorage.getItem('oauth_pending'));
  if (!pending || pending.state !== state) {
    throw new Error('Invalid state');
  }

  const MAX_FLOW_AGE_MS = 10 * 60 * 1000;
  if (Date.now() - pending.created_at > MAX_FLOW_AGE_MS) {
    sessionStorage.removeItem('oauth_pending');
    throw new Error('OAuth flow expired - restart login');
  }

  sessionStorage.removeItem('oauth_pending');

  const response = await fetch('/api/oauth/exchange', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({ code, code_verifier: pending.code_verifier }),
  });

  if (!response.ok) throw new Error('Exchange failed');

  const { access_token, expires_in } = await response.json();
  return { access_token, expires_in };
}
app.post('/api/oauth/exchange', async (req, res) => {
  const { code, code_verifier } = req.body;

  const response = await fetch(`${AUTH_BASE_URL}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      code_verifier,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  if (!response.ok) {
    return res.status(response.status).json(await response.json());
  }

  const { access_token, refresh_token, expires_in, scope } =
    await response.json();

  res.cookie('bb_refresh', refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/api/oauth/refresh',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });

  res.json({ access_token, expires_in, scope });
});
Chef’s warning - Authorization codes are short-lived and single-use. If your callback handler consumes the code before your script reaches it, exchange returns invalid_grant. Use a callback URL that does nothing automatic, or exchange the code immediately.
Tasting note - On a member’s first successful OAuth completion, two wallets are automatically minted for them: a MEMBERSHIP wallet and a SPENDING wallet.

Step 3 - Make API calls

The access token goes in Authorization: Bearer .... Tokens last 60 minutes.
async function callApi(path, options = {}) {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (response.status === 401) {
    await refreshAccessToken();
    return callApi(path, options);
  }

  return response;
}
Tasting note - Member routes resolve the subject from your token. A call to /users/me/wallets returns the authenticated member’s wallets: there’s no member UUID in the path, and you can’t read another member’s data with the token. Each member route is gated by scope (read:wallets, read:checkins, read:profile); a token missing the required scope returns 403 insufficient_scope. Never expose the token client-side beyond the in-memory pattern in Security notes.

Step 4 - Refresh

When the access token expires, your frontend asks the backend for a new one. The backend reads the refresh token from the cookie, exchanges it at /oauth/token, rotates the cookie, and returns a fresh access token.
app.post('/api/oauth/refresh', async (req, res) => {
  const refreshToken = req.cookies.bb_refresh;
  if (!refreshToken) {
    return res.status(401).json({ error: 'no_refresh_token' });
  }

  const response = await fetch(`${AUTH_BASE_URL}/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,
      client_secret: CLIENT_SECRET,
    }),
  });

  if (!response.ok) {
    return res.status(response.status).json(await response.json());
  }

  const { access_token, refresh_token, expires_in } =
    await response.json();

  res.cookie('bb_refresh', refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/api/oauth/refresh',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });

  res.json({ access_token, expires_in });
});
Chef’s warning - Refresh tokens are rotated on every use. Each successful refresh returns a new refresh_token that replaces the previous one. Reusing the old token returns 400 invalid_grant.

Identify the authenticated member

/users/me/* is the canonical way to read the authenticated member: the subject is resolved server-side from the token, so you rarely need the UUID at all. When you do need it (to key your own state, for example), it lives in the access token’s sub claim. Decode it client-side or backend-side.
// Node 18+
function getAuthenticatedUserId(accessToken: string): string {
  const payload = JSON.parse(
    Buffer.from(accessToken.split('.')[1], 'base64url').toString('utf-8'),
  );
  return payload.sub;
}
// Browser
function getAuthenticatedUserId(accessToken: string): string {
  const part = accessToken.split('.')[1];
  const padded = part
    .replace(/-/g, '+')
    .replace(/_/g, '/')
    .padEnd(part.length + ((4 - (part.length % 4)) % 4), '=');
  return JSON.parse(atob(padded)).sub;
}
Every Flynet access token carries these claims: sub, client_id, scope, aud, iss, iat, exp, jti. The sub is a Flynet user UUID; it matches the id returned by /users/me.
From the kitchen - Decode sub once when you receive a fresh access token, then cache it alongside the token in memory. Re-decode on every refresh: sub is stable per member, but explicit is better than assumed.

Security notes

  • Keep the access token in memory only. Do not put it in localStorage or sessionStorage.
  • Store the refresh token in an HttpOnly cookie scoped to /api/oauth/refresh.
  • Blackbird allowlists your registered redirect URI’s origin when your app is provisioned.
  • JWT issuer claim. Tokens carry iss: https://api-staging.blackbird.xyz (staging). Note the dash between api and staging. The API host itself uses a dot: api.staging.blackbird.xyz. Both are valid; the hyphen form is the auth tenant, the dot form is the API gateway. If you verify JWT signatures or check iss, expect the hyphen form.

Token TTLs

TokenLifetime
Access token60 minutes
Refresh tokenUp to 30 days, rotated on each use

Token response shape

{
  "access_token": "<jwt>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "<refresh>",
  "scope": "read:profile read:checkins"
}

Common error responses

CodeWhen
invalid_grantAuthorization code expired, was consumed, did not match code_verifier, or a rotated refresh token was reused.
invalid_clientWrong client_id or client_secret.
invalid_requestMalformed request, missing parameter, or wrong scope name.