# Wallet sign-in in your own Next.js app (/docs/build/self-hosted-wallet-sign-in)



Your Next.js app signs users in with their wallet, the way Scaffold-ETH 2, create-solana-dapp and most wagmi apps already do. Domino then uses that login: your server tells Domino which wallet signed in, and members collect quests and points under it without a second sign-in.

This is [app login](/docs/build/app-login) with a wallet as your app's user. Your server checks the wallet's signature itself with `verifyWalletSignIn`, so it works on any host, `localhost` included.

```mermaid
sequenceDiagram
    actor User
    participant Browser
    participant Server as Your Next.js server
    participant Domino
    Browser->>Server: GET /api/wallet/nonce
    Server-->>Browser: A fresh nonce, kept in the session
    User->>Browser: Signs the sign-in message in the wallet
    Browser->>Server: POST /api/wallet/verify with message and signature
    Server->>Server: verifyWalletSignIn checks domain, nonce, expiry, chain and signature
    Browser->>Server: Participant request at /relay
    Server->>Domino: Exchange for the wallet's member, vouching the wallet
    Domino-->>Server: Quests and progress
```

## Before you start [#before-you-start]

* **An app login key.** An organization admin creates one in Console under **Settings → App login keys** in **Test**, with the app's address as its origin. For local development that is `http://localhost:3000`; see [develop on localhost](#develop-on-localhost). Save it in the server's environment as `APP_IDENTITY_KEY`.
* **Allow vouching on the key.** Under **Connected apps**, choose **Allow vouching** on the key, give a reason, and confirm. This needs an organization admin with **Publish** permission. Your server can then attach the wallet to the member; see [vouch for your members' accounts](/docs/build/app-login#vouch-for-your-members-accounts).
* **A session library.** The examples keep the nonce and the signed-in wallet in an encrypted cookie session, such as `iron-session`'s. `appSession()` stands for yours.

## Sign in with Ethereum [#sign-in-with-ethereum]

### 1. Issue a nonce [#1-issue-a-nonce]

The server issues a fresh nonce for each sign-in and keeps it in the session, and the verify route clears it before checking the message, so the browser can't send the same message twice. A cookie-only session, such as `iron-session`'s, keeps no record of a cleared nonce: someone holding the earlier cookie and the signed message can replay both until the message expires, at most 10 minutes later. Store sessions on the server when each message must work once.

```ts
// app/api/wallet/nonce/route.ts
import { randomBytes } from "node:crypto"
import { appSession } from "./your-app-session"

export async function GET() {
  const session = await appSession()
  session.nonce = randomBytes(16).toString("hex")
  await session.save()

  return Response.json({ nonce: session.nonce })
}
```

### 2. Sign the message in the browser [#2-sign-the-message-in-the-browser]

Build an EIP-4361 message with viem for your page's host and the chain the wallet is on, and ask the wallet to sign it.

```ts
"use client"
import "viem/window"
import { createWalletClient, custom } from "viem"
import { createSiweMessage } from "viem/siwe"

export async function signInWithEthereum() {
  const wallet = createWalletClient({ transport: custom(window.ethereum!) })
  const [address] = await wallet.requestAddresses()
  const { nonce } = await (await fetch("/api/wallet/nonce")).json()
  const message = createSiweMessage({
    domain: window.location.host,
    address,
    statement: "Sign in to Booth.",
    uri: window.location.origin,
    version: "1",
    chainId: await wallet.getChainId(),
    nonce,
    issuedAt: new Date(),
    expirationTime: new Date(Date.now() + 5 * 60 * 1000),
  })
  const signature = await wallet.signMessage({ account: address, message })
  const answer = await fetch("/api/wallet/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message, signature }),
  })

  if (!answer.ok) {
    throw new Error("Wallet sign-in failed")
  }
}
```

With wagmi, take `address` and `chainId` from `useAccount()` and sign with `useSignMessage().signMessageAsync({ message })`. With RainbowKit, call the same two routes from your authentication adapter's `getNonce` and `verify`.

### 3. Verify it on the server [#3-verify-it-on-the-server]

`verifyWalletSignIn` from `@domino-sdk/relay/server` checks the message without calling any network. It refuses it unless its domain is your origin's host, its nonce is the session's, it is no older than 10 minutes and not past its expiry, its chain is one of `chains`, and the signature is the address's.

```ts
// app/api/wallet/verify/route.ts
import { isWalletSignInError, verifyWalletSignIn } from "@domino-sdk/relay/server"
import { appSession } from "./your-app-session"

export async function POST(request: Request) {
  const session = await appSession()
  const { message, signature } = await request.json()
  const nonce = session.nonce
  // Clear the nonce before checking, so a refused message can't be retried with it.
  session.nonce = undefined
  await session.save()

  if (!nonce) {
    return Response.json({ error: "Ask for a nonce first" }, { status: 400 })
  }

  try {
    const signedIn = await verifyWalletSignIn({
      message,
      signature,
      origin: process.env.APP_ORIGIN!,
      nonce,
      chains: ["eip155:1", "eip155:8453"],
    })
    session.wallet = signedIn.account
    await session.save()

    return Response.json({ address: signedIn.account.address })
  } catch (error) {
    if (isWalletSignInError(error)) {
      return Response.json({ error: "Sign-in failed", code: error.code }, { status: 401 })
    }

    throw error
  }
}
```

`APP_ORIGIN` is the exact origin of the app, such as `http://localhost:3000` or `https://booth.example.com`, the one the app login key names.

> [!DEV] What verifyWalletSignIn answers
> It resolves `{ account, accountId, chainId, issuedAt, expiresAt }`: `account` is the wallet as `authenticate` returns it in `accounts` (`{ kind: "wallet", chain: "eip155", address }`, the address EIP-55 checksummed), and `accountId` its CAIP-10 ID, such as `eip155:8453:0xAb…`. A refused message throws `WalletSignInError` with `code` set to `invalid-message`, `wrong-domain`, `wrong-nonce`, `expired`, `not-yet-valid`, `unsupported-chain` or `invalid-signature`; `isWalletSignInError(error)` recognizes it. `maxAge` changes the 10-minute limit, in milliseconds.
>
> It checks signatures from wallets with their own key. A smart-contract wallet (ERC-1271), such as a Safe, signs through its contract, which this check cannot read, so its sign-in is refused with `invalid-signature`.

## Sign in with Solana [#sign-in-with-solana]

Solana wallets that support Sign-In with Solana build and sign the message themselves: call the Wallet Standard `solana:signIn` feature with `domain`, `nonce`, `uri` and `issuedAt`, then send the signed message as text and the signature to the same verify route. Pass the signature's bytes or base58 as `signature`, and the Solana clusters you accept as `chains`, such as `["solana:mainnet"]`. A message without a `Chain ID` line means mainnet. `account` is then `{ kind: "wallet", chain: "solana", address }`.

## Connect Domino [#connect-domino]

Mount `createParticipantBackend` in a catch-all route handler under `/relay`. Its `authenticate` reads the wallet from your session, signs the member in under it, and vouches for the wallet so quests that read a member's wallet see it.

```ts
// app/relay/[...path]/route.ts
import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy"
import { appSession } from "./your-app-session"

const participant = createParticipantBackend({
  baseUrl: "https://relay.domino.run",
  origin: process.env.APP_ORIGIN!,
  integrationKey: process.env.APP_IDENTITY_KEY!,
  async authenticate() {
    const { wallet } = await appSession()

    return wallet
      ? { subject: `${wallet.chain}:${wallet.address}`, accounts: [wallet] }
      : null
  },
})

export { participant as GET, participant as POST }
```

In the browser, use `createBrowserRelayClient({ baseUrl: "/relay" })`, or `RelayProvider` with it in React, as [app login](/docs/build/app-login#3-connect-the-browser) describes. After your verify route answers, and after your app signs out, call `useSession().refresh()`.

The subject is your app's user ID for this person, here the wallet itself, so the same wallet always signs in as the same member.

## Pay prizes to the wallet [#pay-prizes-to-the-wallet]

Domino attaches the wallet your server vouches for as a vouched wallet: quests and trackers count it right away, but a leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list) leaves it out, because Domino didn't see the wallet sign anything itself.

When a prize goes to the wallet, have the member link it once more through Domino. Call `relay.wallets.link(signer)` from the browser client, or `link` from `useWallets()`, with the same wallet. The wallet signs Domino's own message for your page's domain, and the wallet becomes one the member proved, which payout lists include.

> [!DEV] A signer for a wagmi or viem wallet
> `link` takes a `WalletSigner`. For a viem wallet client, return `{ chain: "eip155", address: async () => address, sign: async (message) => ({ signature: await wallet.signMessage({ account: address, message }) }) }`, or use `discoverEvmWallets()` from `@domino-sdk/relay/browser`. See [link a wallet](/docs/build/wallets#link-a-wallet).

## Develop on localhost [#develop-on-localhost]

A **Test** app login key accepts a loopback origin as well as HTTPS: `http://localhost:<port>`, `http://127.0.0.1:<port>` or `http://[::1]:<port>`. Create one for `http://localhost:3000`, set `APP_ORIGIN=http://localhost:3000`, and `next dev` signs members in to your project's **Test** environment against real Domino.

* **Live keys need HTTPS.** Console refuses a loopback origin on a **Live** key: "Live app login keys need an HTTPS origin. Loopback origins such as [http://localhost:3000](http://localhost:3000) work only on Test keys."
* **Each port is its own origin.** A key for `http://localhost:3000` refuses requests from `http://localhost:3001`. Create one key per address the app runs at.
* **Other addresses on plain HTTP are refused.** Console answers "Use an HTTPS origin, or a loopback origin such as [http://localhost:3000](http://localhost:3000) for a Test key." Reach the app through a tunnel with an HTTPS address, and create a key for that address, to test from a phone.

When you deploy, create a **Live** key for the deployed HTTPS address and set `APP_ORIGIN` to it.
