getAccessToken.
Prerequisites
- A registered
client_idandredirect_urifrom Passage - contact us if you need access redirect_urimust match your OAuth callback page (for examplehttps://your-domain.com/oauth/coinlist/callback)client_secretavailable only on the server@passage-cl/reactinstalled (SDK quickstart)
Wrap your app with PassageProvider
ConfigureclientId, 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
nullwhen 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.
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.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.
Sign-in UI (recommended)
Use the packaged sign-in card inside any subtree that already hasPassageProvider. It calls passage.auth.startOAuth() when the user clicks Continue with CoinList. Pass onSignIn to override that.
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 bindscreatePassageServer 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 writableSessionStore 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
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: checktoken.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()andauth.logout()reject withWritableSessionStoreRequiredErrorbefore any network request. Use a writable store in your Route Handlers for those flows.passage.tokensstill works. It is unauthenticated, so token metadata reads need no session at all.
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 asredirect_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 rawfetch 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’sgetAccessToken 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
PassageServerinstances in one process (one JavaScript module instance) that refresh the same session join a singlePOST /oauth/token, and each writes the result to its ownSessionStore. - 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 returnsnull, 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’sonSessionExpired. 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_clientor 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 thefetcherror) and leaves the session untouched. OtherPassageServermethods that need a session throw that same error instead ofNotAuthenticatedError. - 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
expiresAtin the session your store returns makesauth.getAccessToken()throw aValidationError.
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.
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: Beareron 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_secretor 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.Custom sign-in control (usePassage + startOAuth)
Custom sign-in control (usePassage + startOAuth)
If you build your own button or entry point, call
auth.startOAuth() from usePassage():Manual callback handling (auth.completeOAuth + fetch)
Manual callback handling (auth.completeOAuth + fetch)
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.Imperative client (createPassageClient)
Imperative client (createPassageClient)
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.