Authenticate users with Passage and authorize your application using OAuth 2.0 with PKCE and a backend session.
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 recipe 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.
Configure clientId, redirectUri, and getAccessToken so the SDK can run OAuth in the browser and read short-lived access tokens from your session API.
// app/providers.tsx"use client";import { ClientId, CoinListProvider, RedirectUri,} from "@coinlist-co/react";import type { ClientConfig } from "@coinlist-co/react";export function Providers({ children }: { children: React.ReactNode }) { const config: ClientConfig = { clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!), redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!), getAccessToken: async () => { const res = await fetch("/api/coinlist/oauth/access-token", { credentials: "include", }); if (res.status === 204) return null; if (!res.ok) { throw new Error("GET /api/coinlist/oauth/access-token failed"); } const data = (await res.json()) as { value: string; expiresAt: string }; return { value: data.value, expiresAt: new Date(data.expiresAt), }; }, }; return <CoinListProvider config={config}>{children}</CoinListProvider>;}
If you omit baseUrl in ClientConfig, the SDK uses its default API base URL. Set baseUrl when you need the SDK to call a different host (for example your own backend proxy).
Use the packaged sign-in card inside any subtree that already has CoinListProvider. It calls coinlist.startOAuth() when the user clicks Sign in with CoinList.
"use client";import { CoinListSignInCard } from "@coinlist-co/react";export function SignInPanel() { return <CoinListSignInCard />;}
Importing components from @coinlist-co/react pulls in the SDK styles needed for this UI.For auth gating, loading states, and OffersGrid after sign-in, see partner-demo app/page.tsx.
Create a small factory that binds createCoinListServer to your outgoing response and session store. The example below uses HTTP-only cookies; adjust to your security model.
// lib/coinlist-server.tsimport type { NextResponse } from "next/server";import type { CoinListServer } from "@coinlist-co/react/server";import { ClientId, ClientSecret, createCoinListServer, RedirectUri,} from "@coinlist-co/react/server";import { createSessionCookiesStore } from "./session-store";export function coinListServer(outgoingResponse: NextResponse): CoinListServer { return createCoinListServer({ clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!), clientSecret: ClientSecret(process.env.COINLIST_CLIENT_SECRET!), redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!), sessionStore: createSessionCookiesStore(outgoingResponse), });}
// lib/session-store.tsimport { cookies } from "next/headers";import { NextResponse } from "next/server";import type { OAuthSession, SessionStore } from "@coinlist-co/react/server";import { OAuthRefreshToken } from "@coinlist-co/react/server";const COINLIST_SESSION_COOKIE = "coinlist_session";/** Copy Set-Cookie headers from the SDK scratch response onto the response you return (e.g. after refresh in `accessToken()`). */export function copyCookiesFromTo(source: NextResponse, target: NextResponse) { for (const cookie of source.cookies.getAll()) { const { name, value, ...options } = cookie; target.cookies.set(name, value, options); }}export function createSessionCookiesStore( outgoingResponse: NextResponse,): SessionStore { return { async getSession(): Promise<OAuthSession | null> { const raw = (await cookies()).get(COINLIST_SESSION_COOKIE)?.value; if (!raw) return null; let parsed: { accessToken?: { value?: string; expiresAt?: string }; refreshToken?: string; }; try { parsed = JSON.parse(raw); } catch { return null; } if (!parsed.accessToken?.value || !parsed.accessToken?.expiresAt) return null; const expiresAt = new Date(parsed.accessToken.expiresAt); if (Number.isNaN(expiresAt.getTime())) return null; return { accessToken: { value: parsed.accessToken.value, expiresAt }, ...(parsed.refreshToken ? { refreshToken: OAuthRefreshToken(parsed.refreshToken) } : {}), }; }, async setSession(session: OAuthSession | null): Promise<void> { if (session == null) { outgoingResponse.cookies.delete(COINLIST_SESSION_COOKIE); return; } outgoingResponse.cookies.set( COINLIST_SESSION_COOKIE, JSON.stringify({ accessToken: { value: session.accessToken.value, expiresAt: session.accessToken.expiresAt.toISOString(), }, ...(session.refreshToken ? { refreshToken: String(session.refreshToken) } : {}), }), { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", }, ); }, };}
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.
// lib/coinlist-server-readonly.tsimport { cookies } from "next/headers";import type { OAuthSession, SessionStore } from "@coinlist-co/react/server";import { ClientId, ClientSecret, RedirectUri, createCoinListServer,} from "@coinlist-co/react/server";// Read-only store: only `getSession`, no `setSession`.const readOnlySessionStore: SessionStore = { async getSession(): Promise<OAuthSession | null> { const raw = (await cookies()).get("coinlist_session")?.value; // …parse and return, same as the writable store above return parseSession(raw); },};export function coinListServerForRSC() { return createCoinListServer({ clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!), clientSecret: ClientSecret(process.env.COINLIST_CLIENT_SECRET!), redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!), sessionStore: readOnlySessionStore, });}
What the SDK does in read-only mode:
accessToken() returns the current token. If it’s expired, the SDK surfaces the 401 immediately instead of attempting a refresh (it won’t waste a network round-trip with a token it knows is dead).
completeOAuth() and logout() throw WritableSessionStoreRequiredError synchronously, before any network call. Use a writable store in your Route Handlers for those flows.
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 completeOAuth, accessToken()-driven refresh, and logout. Use the read-only store inside Server Components for plain reads (for example, pre-fetching offers with CoinListServer.fetchOffers() for SSR).
On the URL you registered as redirect_uri, use useCompleteOAuth: it validates the redirect (state / PKCE via coinlist.completeOAuth), POSTs to your complete endpoint, then runs coinlist.init() so getAccessToken sees the new session.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.
The browser SDK calls getAccessToken whenever it needs a Bearer token. Implement this route with coinlistServer(response).accessToken() so refresh happens server-side.
// app/api/coinlist/oauth/access-token/route.tsimport { coinListServer } from "@/lib/coinlist-server";import { copyCookiesFromTo } from "@/lib/session-store";import { NextResponse } from "next/server";export async function GET() { const cookieSink = new NextResponse(null, { status: 204 }); const token = await coinListServer(cookieSink).accessToken(); if (token == null) { return cookieSink; } const response = NextResponse.json({ value: token.value, expiresAt: token.expiresAt.toISOString(), }); copyCookiesFromTo(cookieSink, response); return response;}
When accessToken() 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.
Use the sections below only if you are not using the defaults above.
Custom sign-in control (useCoinList + startOAuth)
If you build your own button or entry point, call startOAuth() from useCoinList():
"use client";import { useCoinList } from "@coinlist-co/react";export function LoginButton() { const { coinlist } = useCoinList(); return ( <button type="button" onClick={() => void coinlist.startOAuth()}> Sign in with CoinList </button> );}
Manual callback handling (completeOAuth + fetch)
You can call coinlist.completeOAuth() yourself, then POST code and codeVerifier to /api/coinlist/oauth/complete, await coinlist.init(), and redirect. Prefer useCompleteOAuth when possible - it centralizes failure reasons and avoids duplicate work in React Strict Mode.
For non-React browser code, import createCoinListClient and ClientConfig from @coinlist-co/react and call the same methods (startOAuth, completeOAuth, fetchOffers, …). You still need a backend for token exchange in production.