> ## Documentation Index
> Fetch the complete documentation index at: https://docs.passage.coinlist.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Set up OAuth authentication

> 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 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](mailto:support@coinlist.co) 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
* `@coinlist-co/react` installed ([SDK quickstart](/sdk/quickstart))

Working examples line up with the [partner-demo](https://github.com/coinlist/partner-demo) app.

## Wrap your app with CoinListProvider

Configure `clientId`, `redirectUri`, and `getAccessToken` so the SDK can run OAuth in the browser and read short-lived access tokens from your session API.

```tsx theme={null}
// 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>;
}
```

<Note>
  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).
</Note>

## Sign-in UI (recommended)

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**.

```tsx theme={null}
"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`](https://github.com/coinlist/partner-demo/blob/main/app/page.tsx).

## Server: CoinListServer and session storage

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.

```ts theme={null}
// lib/coinlist-server.ts
import 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),
  });
}
```

```ts theme={null}
// lib/session-store.ts
import { 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: "/",
        },
      );
    },
  };
}
```

### 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.

```ts theme={null}
// lib/coinlist-server-readonly.ts
import { 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.

<Warning>
  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](/changelogs/react#v0-5-1) for the full migration note.
</Warning>

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).

## Callback route

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`](https://github.com/coinlist/partner-demo/blob/main/app/oauth/coinlist/callback/page.tsx):

```tsx theme={null}
// app/oauth/coinlist/callback/page.tsx
"use client";

import { useCompleteOAuth } from "@coinlist-co/react";
import type { CompleteOAuthFailureReason } from "@coinlist-co/react";
import { useRouter } from "next/navigation";

export default function CoinListCallbackPage() {
  const router = useRouter();

  useCompleteOAuth({
    async postOAuthComplete({ code, codeVerifier }) {
      const res = await fetch("/api/coinlist/oauth/complete", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ code, codeVerifier }),
      });
      return res.ok;
    },
    onFailure(reason: CompleteOAuthFailureReason) {
      router.replace(`/?error=${encodeURIComponent(reason)}`);
    },
    onSuccess() {
      router.replace("/");
    },
  });

  return (
    <div className="flex min-h-screen items-center justify-center">
      <p>Completing sign-in…</p>
    </div>
  );
}
```

<Note>
  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`.
</Note>

## 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.

```ts theme={null}
// app/api/coinlist/oauth/complete/route.ts
import { NextResponse } from "next/server";
import { AuthorizationCode, CodeVerifier } from "@coinlist-co/react/server";

import { coinListServer } from "@/lib/coinlist-server";

interface CompleteOAuthRequest {
  code: string;
  codeVerifier: string;
}

export async function POST(req: Request) {
  const body: CompleteOAuthRequest = await req.json();
  const { code, codeVerifier } = body;

  if (typeof code !== "string" || typeof codeVerifier !== "string") {
    return NextResponse.json({ error: "missing_fields" }, { status: 400 });
  }

  const response = NextResponse.json({ ok: true });

  await coinListServer(response).completeOAuth(
    AuthorizationCode(code),
    CodeVerifier(codeVerifier),
  );

  return response;
}
```

## Serve access tokens to the client

The browser SDK calls `getAccessToken` whenever it needs a Bearer token. Implement this route with `coinlistServer(response).accessToken()` so refresh happens server-side.

```ts theme={null}
// app/api/coinlist/oauth/access-token/route.ts
import { 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;
}
```

<Note>
  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.
</Note>

## Log out

Clear the session with the server SDK from a route handler:

```ts theme={null}
// app/api/coinlist/oauth/logout/route.ts
import { coinListServer } from "@/lib/coinlist-server";
import { NextResponse } from "next/server";

export async function POST() {
  const response = NextResponse.json({ ok: true });
  await coinListServer(response).logout();
  return response;
}
```

Call `coinlist.logout()` on the client when you need to drop the in-memory token after the server clears the session.

## 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, `accessToken()` refreshes when possible.
* 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.

<AccordionGroup>
  <Accordion title="Custom sign-in control (useCoinList + startOAuth)">
    If you build your own button or entry point, call `startOAuth()` from `useCoinList()`:

    ```tsx theme={null}
    "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>
      );
    }
    ```
  </Accordion>

  <Accordion title="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.

    ```tsx theme={null}
    "use client";

    import { useCoinList } from "@coinlist-co/react";
    import { useRouter, useSearchParams } from "next/navigation";
    import { Suspense, useEffect, useRef } from "react";

    function LegacyCallbackBody() {
      const { coinlist } = useCoinList();
      const searchParams = useSearchParams();
      const router = useRouter();
      const hasStarted = useRef(false);

      useEffect(() => {
        if (hasStarted.current) return;

        if (searchParams.get("error")) {
          router.replace("/?error=coinlist_denied");
          return;
        }

        if (!searchParams.get("code")) return;

        const oauthRes = coinlist.completeOAuth();
        if (oauthRes.type === "error") {
          router.replace("/?error=coinlist_oauth");
          return;
        }

        hasStarted.current = true;

        void (async () => {
          const res = await fetch("/api/coinlist/oauth/complete", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            credentials: "include",
            body: JSON.stringify({
              code: oauthRes.code,
              codeVerifier: oauthRes.codeVerifier,
            }),
          });

          if (!res.ok) {
            hasStarted.current = false;
            router.replace("/?error=coinlist_complete_failed");
            return;
          }

          await coinlist.init();
          router.replace("/");
        })();
      }, [coinlist, searchParams, router]);

      return <p>Completing sign-in…</p>;
    }

    export default function LegacyCallbackPage() {
      return (
        <Suspense fallback={<p>Completing sign-in…</p>}>
          <LegacyCallbackBody />
        </Suspense>
      );
    }
    ```
  </Accordion>

  <Accordion title="Imperative client (createCoinListClient)">
    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.
  </Accordion>
</AccordionGroup>

## Next step

<Card title="Fetch offers" icon="grid" href="/sdk/fetch-offers" horizontal>
  List offers for the signed-in user with `OffersGrid` or hooks, then open [Display offer details](/sdk/sale-details) for one offer.
</Card>

## Resources

* [OAuth 2.0 Playground](https://oauth.net/playground/)
* [PKCE authorization code flow (Auth0)](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-pkce)
