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.
Tasting note - The published @flynetdev/core SDK implements this flow for you: FlynetOAuth handles PKCE generation, the code exchange, and refresh-token rotation, and FlynetMemberClient carries the resulting access token on member routes. The walkthrough below shows the raw HTTP if you’d rather wire it by hand.

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:user_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

Staging:
Production (separate credentials, issued when you’re approved for live traffic):
Tasting note — local testing. Your redirect_uri must be a public, allowlisted URL, and the allowlist is an exact match. localhost usually won’t be registered, so expose your callback with a tunnel like ngrok and register that exact URL:
Use a reserved ngrok domain (free tier includes one) so the URL is stable — because the allowlist is exact, a rotating hostname means re-registering with Blackbird every session.

The flow

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.
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.
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.
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:user_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.
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.
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 as an exact match — scheme, host, port, and path, character for character. A trailing slash or a different path is rejected. Register every callback URL you’ll use (including a local-testing one; see Environment).
  • 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

Token response shape

Common error responses