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

# Sign in with wallet

> Let a user with no CoinList account sign in with their wallet: the backend endpoints, the provider prop, and the safety checks you own.

Wallet sign-in gives a user with no CoinList account a session for one wallet on one network. It works like a CoinList login: an explicit sign-in step, a session on your backend, and a sign-out. Unlike a CoinList login, it also ends when the wallet disconnects or changes.

The SDK ships no sign-in button and connects no wallet. You bring both, and the SDK runs the sign-in and the sign-out.

> [!NOTE]
>
> Wallet sign-in needs v0.17.0 or later, and it is React-only for now: your backend requests and your wallet connection reach the SDK through `PassageProvider`.

## How it works

1. The browser asks your backend for a challenge. Your backend gets a Sign-In With Ethereum (SIWE) message from CoinList with `auth.walletChallenge`.
2. The SDK asks the wallet to sign the message, then sends the signature to your backend.
3. Your backend exchanges the signature with `auth.completeWalletSignIn`, which stores the session as a CoinList login does.
4. The browser reads the token from your access-token endpoint. A browser holds one session at a time, so a wallet sign-in replaces a CoinList login.

## What a wallet session can do

A wallet session has no CoinList account behind it, so it reaches only part of the SDK.

* **Works:** Ondo buy and sell; Centrifuge buy, sell, and claim; and `requirements.forOffer`.
* **Throws `NotAuthenticatedError`:** `offers`, `wallets`, Superstate, CoinList token sales, and every other `requirements` method.

So read offers on your server with an app-level token.

An option's requirements decide which sign-in it needs. CoinList checks every requirement against a CoinList user, so an option with any requirement needs a CoinList login, and only an option with none can use a wallet session. `signInMethodFor` applies this rule, so your sign-in button and the checkout agree:

```ts theme={null}
import { signInMethodFor } from "@passage-cl/react/universal";

const clientCreds = await passage.auth.clientCredentials();
const offers = await passage.offers.list(clientCreds);
const requirements = await passage.requirements.forOffer(offerId, clientCreds);

// "wallet" or "coinlist"
const signIn = signInMethodFor(requirements[optionId] ?? []);
```

## Set up your OAuth client

Wallet sign-in requires these settings on your OAuth client. CoinList configures it, so [contact us](mailto:support@coinlist.co) to set them:

| Setting | Without it |
| - | - |
| **Wallet Sign-In (SIWE)** turned on | `auth.walletChallenge` throws an `HttpError` with status 403. |
| Your `redirectUri` listed as a redirect URI | `auth.walletChallenge` throws an `HttpError` with status 422. The challenge signs for this URI. |
| `client_credentials` grant type | The challenge and the signature exchange both fail. |
| `revoke` grant type | Sign-out looks fine, but the token stays valid until it expires. `auth.logout` logs the failed revoke and clears the session anyway. |
| `refresh_token` grant type | The session ends when its first access token expires. |

## Add the backend endpoints

You need four endpoints. Two are new, and the other two are the ones a CoinList login uses. The samples build on the `passageServer` factory and session store from [Set up OAuth authentication](https://docs.passage.coinlist.co/sdk/oauth-authentication.md#server-passageserver-and-session-storage).

| Endpoint | `PassageServer` method | `PassageProvider` setting that requests it |
| - | - | - |
| `POST /api/coinlist/wallet/challenge` | `auth.walletChallenge` | `walletSignIn.challenge` |
| `POST /api/coinlist/wallet/complete` | `auth.completeWalletSignIn` | `walletSignIn.complete` |
| `POST /api/coinlist/oauth/logout` | `auth.logout` | `walletSignIn.signOut` |
| `GET /api/coinlist/oauth/access-token` | `auth.getAccessToken` | `config.getAccessToken` |

### Challenge

Set `statement` on the server, never from the browser's request: the wallet shows it to the user. It can hold up to 256 letters, digits, spaces, and URI punctuation, with no line breaks or double quotes. CoinList answers any other statement with a 422.

Serve `message` to the browser unchanged. The wallet signs it byte for byte, so any change fails the signature.

```ts theme={null}
// app/api/coinlist/wallet/challenge/route.ts
import { passageServer } from "@/lib/passage-server";
import { isRateLimited } from "@/lib/rate-limit";
import {
  type ChallengedWallet,
  isFromThisSite,
  rememberChallenge,
} from "@/lib/wallet-challenge-binding";
import { type NextRequest, NextResponse } from "next/server";

const STATEMENT = "Sign in to Example with your wallet.";

export async function POST(request: NextRequest) {
  if (!isFromThisSite(request)) {
    return new NextResponse(null, { status: 403 });
  }
  if (isRateLimited(request)) {
    return new NextResponse(null, { status: 429 });
  }

  const { walletAddress, chain } = (await request.json()) as ChallengedWallet;
  // A challenge stores no session, so this response is never sent.
  const { message } = await passageServer(new NextResponse()).auth.walletChallenge({
    walletAddress,
    chain,
    statement: STATEMENT,
  });
  return rememberChallenge(NextResponse.json({ message }), { walletAddress, chain });
}
```

### Complete

A challenge is single-use, so the SDK never retries this exchange.

```ts theme={null}
// app/api/coinlist/wallet/complete/route.ts
import { passageServer } from "@/lib/passage-server";
import {
  forgetChallenge,
  isFromChallengingBrowser,
} from "@/lib/wallet-challenge-binding";
import type { WalletSignInGrant } from "@passage-cl/react/universal";
import { type NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const { walletAddress, chain, signature } =
    (await request.json()) as WalletSignInGrant;
  if (!isFromChallengingBrowser(request, { walletAddress, chain })) {
    return new NextResponse(null, { status: 403 });
  }

  const response = NextResponse.json({ ok: true });
  await passageServer(response).auth.completeWalletSignIn({
    walletAddress,
    chain,
    signature,
  });
  return forgetChallenge(response);
}
```

### Access token

Use the [access-token endpoint](https://docs.passage.coinlist.co/sdk/oauth-authentication.md#serve-access-tokens-to-the-client) a CoinList login uses. It serves the token with `signInMethod`, which says how the user signed in and, for a wallet session, which wallet the token acts for.

> [!WARNING]
>
> **Keep `signInMethod` in your session store and your token endpoint.** Without it, a wallet session reads as a CoinList login: CoinList refuses it on CoinList-only requests, and the SDK never signs it out when the wallet leaves. [Keep the sign-in method on the token](https://docs.passage.coinlist.co/sdk/oauth-authentication.md#keep-the-sign-in-method-on-the-token) lists every place it has to survive.

### Sign-out

Use the [logout endpoint](https://docs.passage.coinlist.co/sdk/oauth-authentication.md#log-out) you already have. `auth.logout` revokes the wallet session's token like any other, as long as your OAuth client has the `revoke` grant type.

## Protect the challenge and complete endpoints

These two endpoints are yours to secure. The samples above already call the helpers below.

### Block cross-site requests

Nothing in a signed grant says which browser asked for its challenge. Without a check, a page on another site can post a signature an attacker made for their own wallet, and sign your user into the attacker's wallet session.

So refuse requests from other sites on both endpoints, and accept a grant only from the browser that asked for that wallet's challenge. An `Origin` check does the first, and a `SameSite=Strict` cookie does the second:

```ts wallet-challenge-binding.ts theme={null}
// lib/wallet-challenge-binding.ts
import type { WalletSignInGrant } from "@passage-cl/react/universal";
import type { NextRequest, NextResponse } from "next/server";

export type ChallengedWallet = Pick<WalletSignInGrant, "walletAddress" | "chain">;

const CHALLENGE_COOKIE = "coinlist_wallet_challenge";
const COOKIE_PATH = "/api/coinlist/wallet";
const SITE_ORIGIN = new URL(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!).origin;
// A challenge lives 10 minutes.
const CHALLENGE_TTL_SECONDS = 10 * 60;

export function isFromThisSite(request: NextRequest): boolean {
  return request.headers.get("origin") === SITE_ORIGIN;
}

export function rememberChallenge(
  response: NextResponse,
  { walletAddress, chain }: ChallengedWallet,
): NextResponse {
  response.cookies.set(
    CHALLENGE_COOKIE,
    JSON.stringify({ walletAddress: walletAddress.toLowerCase(), chain }),
    {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      // Strict, so a cross-site post to the complete endpoint arrives without it.
      sameSite: "strict",
      path: COOKIE_PATH,
      maxAge: CHALLENGE_TTL_SECONDS,
    },
  );
  return response;
}

export function isFromChallengingBrowser(
  request: NextRequest,
  { walletAddress, chain }: ChallengedWallet,
): boolean {
  const raw = request.cookies.get(CHALLENGE_COOKIE)?.value;
  if (!isFromThisSite(request) || !raw) return false;
  try {
    const challenged = JSON.parse(raw) as Partial<ChallengedWallet>;
    return (
      challenged.walletAddress === walletAddress.toLowerCase() &&
      challenged.chain === chain
    );
  } catch {
    return false;
  }
}

export function forgetChallenge(response: NextResponse): NextResponse {
  response.cookies.delete({ name: CHALLENGE_COOKIE, path: COOKIE_PATH });
  return response;
}
```

### Rate-limit the challenge endpoint

The challenge endpoint is public, and each new challenge cancels the last one for that address. Without a limit, anyone can keep canceling a user's challenge, so that user can never finish signing in.

Limit requests per IP, with the rate limiter your platform provides. If you run several instances, count in a shared store, since a counter in one process sees only its own requests.

## Turn it on in the browser

### Pass the `walletSignIn` prop

`walletSignIn` holds your three backend requests and your wallet connection. One prop holds both because a user who signs in with a wallet must be signed out when that wallet leaves, and `connection` is how the SDK finds out.

New functions and a new `connection` object on each render are fine. Only a changed status, address, or chain counts as a wallet change.

```tsx theme={null}
// app/providers.tsx
"use client";

import { PassageProvider } from "@passage-cl/react";
import type {
  ClientConfig,
  WalletConnection,
  WalletSignInConfig,
} from "@passage-cl/react";
import {
  chainFromId,
  ClientId,
  EvmWalletAddress,
  RedirectUri,
} from "@passage-cl/react/universal";
import type {
  EthereumChain,
  SignInMethod,
  SiweMessage,
} from "@passage-cl/react/universal";
import { useRouter } from "next/navigation";
import { useAccount, useDisconnect } from "wagmi";

// Module scope: the provider builds a new client whenever `config` changes
// identity, so do not rebuild it on every render.
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;
      signInMethod: SignInMethod;
    };
    return { ...data, expiresAt: new Date(data.expiresAt) };
  },
};

async function post(path: string, body?: unknown): Promise<Response> {
  const res = await fetch(path, {
    method: "POST",
    credentials: "include",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body ?? {}),
  });
  if (!res.ok) throw new Error(`POST ${path} failed`);
  return res;
}

// Each one resolves on success and throws on any failure.
const walletRequests: Pick<WalletSignInConfig, "challenge" | "complete"> = {
  challenge: async (params) => {
    const res = await post("/api/coinlist/wallet/challenge", params);
    return (await res.json()) as { message: SiweMessage };
  },
  complete: async (grant) => {
    await post("/api/coinlist/wallet/complete", grant);
  },
};

function sdkChain(chainId: number): EthereumChain | null {
  try {
    return chainFromId(String(chainId));
  } catch {
    return null;
  }
}

function useWalletConnection(): WalletConnection {
  const { address, chainId, status } = useAccount();
  // wagmi reconnects the stored wallet after a reload. Reported as
  // `disconnected`, that would sign the user out on every reload.
  if (status === "connecting" || status === "reconnecting") {
    return { type: "connecting" };
  }
  const chain = chainId === undefined ? null : sdkChain(chainId);
  if (status !== "connected" || !address || !chain) {
    return { type: "disconnected" };
  }
  return { type: "connected", address: EvmWalletAddress(address), chain };
}

export function Providers({ children }: { children: React.ReactNode }) {
  const connection = useWalletConnection();
  const { disconnect } = useDisconnect();
  const router = useRouter();
  return (
    <PassageProvider
      config={config}
      walletSignIn={{
        ...walletRequests,
        signOut: async () => {
          disconnect();
          await post("/api/coinlist/oauth/logout");
          // Server Components read the cleared cookie only on a refresh.
          router.refresh();
        },
        connection,
      }}
    >
      {children}
    </PassageProvider>
  );
}
```

Render `Providers` inside your wallet library's provider, so `useAccount` can read the connection.

### Add a sign-in button

`useWalletSignIn` gives you `signIn`, which takes the wallet that signs, and `state`, which drives the button. The wallet must be the one `connection` reports as connected.

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

import { useWalletSignIn } from "@passage-cl/react";
import { EvmWalletAddress } from "@passage-cl/react/universal";
import { useRouter } from "next/navigation";
import { useAccount, useWalletClient } from "wagmi";

export function SignInWithWalletButton() {
  const { address } = useAccount();
  const { data: walletClient } = useWalletClient();
  const { state, signIn } = useWalletSignIn();
  const router = useRouter();

  if (!address || !walletClient) return null;

  const onClick = async () => {
    const result = await signIn({
      address: EvmWalletAddress(address),
      signMessage: (message) =>
        walletClient.signMessage({ account: address, message }),
    });
    // Server Components read the new session cookie only on a refresh.
    if (result.type === "signed-in") router.refresh();
  };

  return (
    <>
      <button disabled={state === "pending"} onClick={() => void onClick()}>
        {state === "pending" ? "Check your wallet..." : "Sign in with wallet"}
      </button>
      {state === "failed" && <p>Could not sign in with your wallet. Try again.</p>}
    </>
  );
}
```

`signIn` resolves with how the sign-in ended:

| `type` | Meaning |
| - | - |
| `signed-in` | The session exists, and the auth state is `logged-in` with `signInMethod.method` set to `wallet`. |
| `canceled` | The user rejected the signature. `state` goes back to `idle`. |
| `failed` | `step` says where: `challenge`, `signature`, or `complete`. Let the user try again. |

### Handle a sign-out

A wallet change, a network change, or a disconnect ends the session like a log-out. So does a checkout on another network. Each time, the SDK requests `walletSignIn.signOut` and drops the token.

The SDK never navigates and never switches networks, so the rest is yours. Disconnect the wallet, as `signOut` does in the sample, and send the user back to the offer page to connect and sign in again: close any open checkout when `usePassage().authState.type` is `logged-out`.

Your own **Sign out** button only has to disconnect the wallet. The SDK sees the disconnect and does the rest.

## Wallet behavior

* **Map a wallet library's "reconnecting" to `connecting`.** Otherwise every reload signs the user out, as the comment in the sample says.
* **Send the message hex-encoded.** `personal_sign` must receive the message in hex, or MetaMask shows a generic signature request and skips its wrong-site warning. viem's and wagmi's `signMessage({ message })` do this for you. If you send the request to the provider yourself, encode the message with viem's `stringToHex`.
* **Serve the sign-in page from your redirect URI's host.** The SDK takes the challenge's domain from `redirectUri`, and the wallet compares that domain with the page asking for the signature. On any other host, the wallet warns the user.
* **A session is for one address on one network.** Sign the user in on the network of the offer they are buying. Pass `disconnected` as the `connection` for a wallet on a network the SDK does not support, as the sample does.
* **Use the same wallet for `connection` and for the checkout's `wallets.external`.** If you pass `wallets.external` without `walletSignIn`, nothing signs the user out when the wallet changes.
* **Checkout on a wallet session supports external wallets only.** The checkout pays from `wallets.external`. An embedded wallet can sign in, because the SDK accepts any signer, but the checkout then asks the user to connect their wallet, with no way forward. The Centrifuge claim card does accept an embedded wallet, so this limit is about checkout.
* **Smart accounts are untested.** We have tested wallet sign-in with externally owned accounts only.

## Next steps

> [card] [Checkout](https://docs.passage.coinlist.co/sdk/checkout.md)
>
> On a wallet session, the checkout opens on the session's wallet, with no wallet step.

> [card] [Wallets](https://docs.passage.coinlist.co/sdk/wallets.md)
>
> Implement `EvmWallet`, the interface a checkout pays through.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.