Skip to main content
Users authenticate with Passage and authorize your application - no passwords are shared with you. Passage uses OAuth 2.0 with PKCE (Proof Key for Code Exchange) to secure the redirect flow. This page assumes you run a backend you control: the client secret, refresh token, and token exchange stay on the server. The React SDK handles PKCE in the browser and session hydration through getAccessToken.

Prerequisites

  • A registered client_id and redirect_uri from Passage - contact us if you need access
  • redirect_uri must match your OAuth callback page (for example https://your-domain.com/oauth/coinlist/callback)
  • client_secret available only on the server
  • @passage-cl/react installed (SDK quickstart)
Working examples line up with the partner-demo app.

Wrap your app with PassageProvider

Configure clientId, redirectUri, and getAccessToken so the SDK can run OAuth in the browser and read short-lived access tokens from your session API.
getAccessToken has two answers, and the SDK treats them differently:
  • Resolve null when there is no session, for example when your endpoint answers 204. The SDK treats the user as logged out.
  • Throw when you could not find out, for example on a 5xx or a network failure. The SDK keeps its current auth state and fails only the request in hand.
Never answer null for your own backend’s failures: that turns every outage into a sign-out. The SDK asks getAccessToken when it initializes (or on its first request, if that comes first), and again when the API refuses the token it holds with a 401. It does not track expiresAt itself. Concurrent requests that need a new token share one call. A null after a 401, or the very token the API just refused, is only a suspicion: the SDK waits one second and asks once more, and only a second such answer signs the user out. That second answer also calls onSessionExpired, if the user had been logged in. It is called on the next microtask and not awaited, and a rejection is logged and dropped. It never fires on auth.logout(), a 5xx, a timeout or a network failure. If getAccessToken throws while the SDK initializes, passage.init() rejects with that error and the auth state stays 'unknown'. PassageProvider still sets isReady to true, so in React isReady can be true while passage.auth.getState() is 'unknown'.
Leave baseUrl and tokensBaseUrl out of ClientConfig to use the production defaults. An empty or whitespace-only value throws a ValidationError when the client is built (in React, from PassageProvider’s render), so omit the field rather than passing an environment variable that is set but empty.
Since v0.11.0 the provider also takes a locale, a BCP-47 tag applied to number, date and currency formatting in every SDK component in the tree. It defaults to 'en-US', a fixed constant rather than navigator.language, so server and client render the same markup. It does not reach embedded third-party flows. The Sumsub identity-verification UI takes its language from identityVerificationOptions.locale on RequirementsChecklistContainer, and falls back to Sumsub’s own default when you omit it.
Use the packaged sign-in card inside any subtree that already has PassageProvider. It calls passage.auth.startOAuth() when the user clicks Continue with CoinList. Pass onSignIn to override that.
If you want the button without the card around it, render CoinListSignInButton instead. It has no default action, so pass an onClick that calls passage.auth.startOAuth(), as in custom sign-in control. You do not import any CSS: every SDK component injects its own styles, scoped to the component, when it renders. For auth gating, loading states, and OffersGridContainer after sign-in, see partner-demo app/page.tsx.

Server: PassageServer and session storage

Create a small factory that binds createPassageServer to your outgoing response and session store. The example below uses HTTP-only cookies; adjust to your security model.
passage-server.ts
session-store.ts

Read-only session stores (Server Components)

In Next.js Server Components you can read cookies but cannot write them, so a writable SessionStore is unsafe - a refreshed session would be silently discarded after consuming the refresh token, effectively logging the user out. Since v0.5.1, setSession is optional: omit it to opt into read-only mode explicitly.
passage-server-readonly.ts
What the SDK does in read-only mode:
  • auth.getAccessToken() returns the stored token as-is, even if it has expired, and logs a warning. It never refreshes, so no refresh token is consumed. A non-null token is not a promise that the API accepts it: check token.expiresAt > new Date() before relying on it.
  • API requests are sent with the stored token. If the API refuses it with a 401, the SDK returns that error without trying to refresh.
  • auth.completeOAuth() and auth.logout() reject with WritableSessionStoreRequiredError before any network request. Use a writable store in your Route Handlers for those flows.
  • passage.tokens still works. It is unauthenticated, so token metadata reads need no session at all.
Don’t fake a writable store with a no-op setSession: async () => {}. That used to be the workaround for Server Components, but it silently consumed refresh tokens during a refresh attempt and logged the user out. If you upgrade from a pre-0.5.1 version, remove any no-op setSession you added. See the v0.5.1 changelog for the full migration note.
Use the writable cookie store (above) inside Route Handlers for auth.completeOAuth, auth.getAccessToken()-driven refresh, and auth.logout. Use the read-only store inside Server Components for plain reads (for example, pre-fetching offers with passage.offers.list() for SSR).

Callback route

On the URL you registered as redirect_uri, use useCompleteOAuth: it validates the redirect (state / PKCE via passage.auth.completeOAuth), hands the code to your postOAuthComplete to exchange on your backend, then runs passage.init() so the client picks up the new session. If postOAuthComplete returns false or rejects, or passage.init() rejects, onFailure receives "complete_request_failed". This matches partner-demo app/oauth/coinlist/callback/page.tsx:
Put this file at a path that matches NEXT_PUBLIC_COINLIST_REDIRECT_URI. Read reason on your home page (for example via useSearchParams) if you want to show OAuth errors - see partner-demo app/page.tsx.

Exchange the code on your backend

The server SDK performs the token exchange - you do not call the token URL with raw fetch in application code. auth.completeOAuth takes an AuthorizationGrant ({ code, codeVerifier }, exported from @passage-cl/react/universal), the same pair the browser’s passage.auth.completeOAuth() returns. It throws HttpError if CoinList refuses the exchange, and it never retries it, because an authorization code is single-use.

Serve access tokens to the client

The browser SDK’s getAccessToken calls this route. Implement it with passageServer(response).auth.getAccessToken() so refresh happens server-side. Map its two outcomes the same way the browser reads them: null means there is no session (answer 204), and a throw means the SDK could not find out (answer a 5xx, so the browser keeps the user signed in and retries later).
When auth.getAccessToken() refreshes the session, the SDK may set cookies on cookieSink. copyCookiesFromTo applies those to the JSON response so the browser gets the updated session cookie.

How the server refreshes

With a writable store, auth.getAccessToken() refreshes the session once the access token is within accessTokenExpiryBufferSeconds of its expiry (default 60 seconds). To change it, pass accessTokenExpiryBufferSeconds: Seconds.of(120) to createPassageServer, with Seconds from @passage-cl/react/universal. Seconds.of throws a ValidationError unless its argument is a non-negative integer.
  • Concurrent requests share one refresh. All PassageServer instances in one process (one JavaScript module instance) that refresh the same session join a single POST /oauth/token, and each writes the result to its own SessionStore.
  • A rejected refresh token is a suspicion first. CoinList answers a refresh that lost a race to a concurrent request the same way it answers a dead session (400 invalid_grant). On the first rejection the SDK writes nothing and returns null, or the current access token if it has not actually expired yet.
  • A second rejection is the verdict. When the same process sees the same refresh token rejected again, 5 to 60 seconds after the first, the SDK clears the session with setSession(null) and then calls the server’s onSessionExpired. A session that has no refresh token is served until it actually expires, then cleared the same way.
  • A failure to find out never clears the session. On a 5xx, a network failure, invalid_client or an unreadable response, the SDK keeps serving the current access token if it has not expired yet, and logs a warning. If it has expired, auth.getAccessToken() throws the underlying error (HttpError, or the fetch error) and leaves the session untouched. Other PassageServer methods that need a session throw that same error instead of NotAuthenticatedError.
  • A 401 forces a refresh. If the API refuses a token that still looks valid locally, for example a session revoked elsewhere, a writable store refreshes anyway.
  • An invalid expiresAt in the session your store returns makes auth.getAccessToken() throw a ValidationError.
onSessionExpired on ServerConfig fires at most once per session per process within 60 seconds, so make it idempotent. When the browser drives renewal through your access-token route, it reaches its own verdict one second after the first rejection, so expect the browser’s onSessionExpired rather than the server’s.

Log out

Clear the session with the server SDK from a route handler. auth.logout() revokes the token with POST /oauth/revoke and clears the session through setSession(null). It does nothing when there is no session.
The revoke is best-effort. If it fails for any reason - an HttpError, a network failure, anything else - the SDK logs a warning and clears the session anyway, because the cleared session is what logs the user out. Set strict: true on createPassageServer to rethrow the revoke failure instead, leaving the session in place so you can retry. In both modes, an error thrown by your SessionStore propagates, and a read-only store rejects with WritableSessionStoreRequiredError. Call passage.auth.logout() on the client to drop the in-memory token after the server clears the session. It is synchronous, it never calls onSessionExpired, and it wins over a getAccessToken answer that was already in flight, so a late answer cannot sign the user back in.

Token management

  • Access tokens are short-lived. The SDK attaches them as Authorization: Bearer on API requests.
  • Refresh tokens are long-lived. Keep them on the server. With the server SDK and a writable store, auth.getAccessToken() refreshes them for you (see how the server refreshes).
  • Never expose client_secret or long-lived refresh tokens to the browser bundle.

Lower-level and custom integrations

Use the sections below only if you are not using the defaults above.
If you build your own button or entry point, call auth.startOAuth() from usePassage():
You can call passage.auth.completeOAuth() yourself, then POST code and codeVerifier to /api/coinlist/oauth/complete, await passage.init(), and redirect. completeOAuth() returns an OAuthClientResult: either { type: "ok", code, codeVerifier } or { type: "error", reason }, where reason is an OAuthClientErrorReason. Prefer useCompleteOAuth when possible - it centralizes failure reasons and avoids duplicate work in React Strict Mode.
For non-React browser code, import createPassageClient and ClientConfig from @passage-cl/react and call the same namespaces (passage.auth.startOAuth(), passage.auth.completeOAuth(), passage.offers.list(), …). Without PassageProvider, call await passage.init() yourself before relying on passage.auth.getState(). It rejects with whatever your getAccessToken throws, leaving the state 'unknown'. You still need a backend for token exchange in production.

Next step

Fetch offers

List offers for the signed-in user with OffersGridContainer or hooks, then open Display offer details for one offer.

Resources