# Sign in with X1ID
URL: /docs/x1id/connect

> X1ID Connect — a passwordless sign-in where a wallet signature proves control of an @handle (or just the wallet). The /v1/auth challenge → sign → verify → session flow, the audience-bound strings a wallet signs, Sign In With Solana, the SDK's signInWithWallet, and how to keep your API key on your server.

# Sign in with X1ID

<Pill tone="mainnet">Mainnet</Pill>

<LlmActions />

**X1ID Connect** lets a user sign in to your app with their wallet — no password, no email. The user signs a one-time challenge; your app gets a session that names their `@handle` (and the platforms they've verified), or just their wallet address. It's the same "a wallet is the identity" model as [app.x1id.io](https://app.x1id.io), exposed as an API.

Every Connect call is **keyed** — your [`X-API-Key`](/docs/x1id/platform#base-url-and-authentication) identifies which app is authenticating the user, and a challenge and session are bound to that key. Connect endpoints are not x402-metered; they always need a key.

<Callout type="warn">
  **What a session proves — and what it does not.** A successful sign-in proves the holder's **current control** of the `@handle` (a wallet signature over a single-use, epoch-bound challenge) and the handle's listed on-chain attestations. It is **not** proof of legal identity, personhood, or KYC. Every Connect response restates this in its `proves` and `disclaimer` fields; surface it honestly to your users. The underlying signing scheme is X1ID's control-proof primitive — a signature the registry is the source of truth for, so a copycat can't fake it.
</Callout>

<Callout type="warn">
  **Your API key stays on your server.** Never ship an X1ID API key in browser code. The browser talks to **your** backend; your backend calls `api.x1id.io/v1/auth/*` and adds `X-API-Key` there. See [Keep your key on the server](#keep-your-key-on-the-server) for the pattern, which works with the SDK helpers unchanged.
</Callout>

## The flow

<Steps>
  <Step>
    ### Request a challenge

    Your backend calls `POST /v1/auth/challenge` with the user's `handle` **or** `wallet`. You get back a one-time `challenge` string, a `nonce`, and the `audience` the challenge is bound to.
  </Step>

  <Step>
    ### The user signs it

    Your frontend asks the user's wallet to sign the **exact** string the server returned — `challenge` for a message signature, `memo` for a transaction signature. Never rebuild it locally: sign the server's bytes verbatim.
  </Step>

  <Step>
    ### Verify and get a session

    Your backend posts the proof to `POST /v1/auth/verify` (message), `/v1/auth/verify-tx` (memo transaction), or `/v1/auth/verify-signin` (Sign In With Solana). A valid proof returns a **Bearer session token**.
  </Step>

  <Step>
    ### Use the session

    Present the token as `Authorization: Bearer <token>` to `GET /v1/auth/session` (or your own protected routes) to read who's signed in.
  </Step>
</Steps>

The quickest way to run all of this from a browser is the SDK's [`signInWithWallet`](#with-the-sdk), pointed at your own backend proxy.

## What the wallet signs

Every challenge names its **audience** inside the signed bytes, so a signature is only redeemable by the application it names. The audience is your API key's **key id** (for the X1ID Developer Portal's own key it is `developers.x1id.io`), and the challenge response returns it as `audience`.

| Path                                   | Exact signed string                                                                                                |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| wallet control (`/verify`)             | `x1id-connect:v2:wallet-control:<audience>:<address>:<nonce>`                                                      |
| handle control (`/verify`)             | `x1id-connect:v2:handle-control:<audience>:` followed by `x1-handles:ctl:v1:<handle>:<epoch>:<nonce>`              |
| memo transaction (`/verify-tx`)        | memo data `x1id-verify:<audience>:<nonce>`                                                                         |
| Sign In With Solana (`/verify-signin`) | a standard SIWS message whose first line names an allowed domain (see [below](#sign-in-with-solana-verify-signin)) |

The server re-derives the expected bytes from the **presenting** key on verify. The older unbound strings — `x1id-connect:v1:wallet-control:<address>:<nonce>`, a memo of `x1id-verify:<nonce>`, or a bare `x1-handles:ctl:v1:…` — and any string bound to a different audience are rejected with `401`. Always sign the `challenge` or `memo` the API returns; don't construct either yourself.

## Challenge

<Endpoint method="POST" path="/v1/auth/challenge" />

Provide **exactly one** of `handle` or `wallet`, plus an optional `mode`.

* **`{ "handle": "@jack" }`** — challenge the owner of a specific handle (a handle-control challenge).
* **`{ "wallet": "<base58 address>" }`** — challenge a wallet. If that wallet has a primary `@handle`, you get a handle-control challenge for it; if it has **no** primary, you get an **address-control** challenge (`handle` is `null`) — the wallet proves control of itself, exactly as connecting a wallet is the identity on app.x1id.io.
* **`"mode": "tx"`** (with `wallet`) — an address-control challenge plus a `memo` for the [transaction path](#sign-in-by-transaction-verify-tx).
* **`"mode": "signin"`** (with `wallet`) — an address-control challenge for [Sign In With Solana](#sign-in-with-solana-verify-signin).

`tx` and `signin` always prove **wallet** control, even when the wallet has a primary `@handle`; the session still reports that handle.

```bash title="POST /v1/auth/challenge (from your backend)"
curl -X POST https://api.x1id.io/v1/auth/challenge \
  -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \
  -d '{ "wallet": "<WALLET>" }'   # a wallet with no primary @handle
```

```json
{
  "nonce": "7f3a…",
  "handle": null,
  "challenge": "x1id-connect:v2:wallet-control:<audience>:<WALLET>:7f3a…",
  "audience": "<your key id>",
  "signature_encoding": "base58",
  "expires_at": 1733270400,
  "proves": "wallet-control+attestations",
  "disclaimer": "Proves the holder's current control of this @handle (a wallet signature) and the listed on-chain attestations only. NOT proof of legal identity, personhood, or KYC."
}
```

| field                | meaning                                                                                              |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| `nonce`              | single-use id for this challenge; pass it back on verify.                                            |
| `handle`             | the canonical handle being proved, or `null` for an address-control challenge.                       |
| `challenge`          | the **exact** string the wallet must sign (raw UTF-8 bytes, no wrapping, no hashing).                |
| `audience`           | the audience bound into the signed bytes: your key id, or `developers.x1id.io` for the portal's key. |
| `memo`               | `mode: "tx"` only — the exact memo data, `x1id-verify:<audience>:<nonce>`.                           |
| `signature_encoding` | `base58` — send a message signature back base58-encoded.                                             |
| `expires_at`         | Unix seconds the challenge expires (5 minutes by default). Request a fresh one after that.           |

## Verify

<Endpoint method="POST" path="/v1/auth/verify" />

The message-signature path. Send the `nonce`, the base58 `signature` over the `challenge` string, and the `signer` (the signing wallet's base58 pubkey). `signer` is required **unless** the challenge was created from `{ wallet }` (then the bound wallet is used). Continuing the example above (a wallet with no primary `@handle`), the session is address-control: The challenge is consumed atomically — a replayed nonce is rejected.

```bash title="POST /v1/auth/verify (from your backend)"
curl -X POST https://api.x1id.io/v1/auth/verify \
  -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \
  -d '{ "nonce": "7f3a…", "signature": "<base58 sig>", "signer": "<WALLET>" }'
```

```json
{
  "token": "<JWT>",
  "token_type": "Bearer",
  "expires_at": 1733356800,
  "handle": null,
  "address": "<WALLET>",
  "verified": [],
  "source": "wallet",
  "proves": "wallet-control+attestations",
  "disclaimer": "Proves the holder's current control of this @handle (a wallet signature) and the listed on-chain attestations only. NOT proof of legal identity, personhood, or KYC."
}
```

| field                  | meaning                                                                                                             |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `token` / `token_type` | the Bearer session token and its type.                                                                              |
| `expires_at`           | Unix seconds the session expires (24 hours by default).                                                             |
| `handle`               | the signed-in handle, or `null` for an address-control session.                                                     |
| `address`              | the wallet that signed.                                                                                             |
| `verified`             | platform slugs the handle holds a live attestation for (`x`, `github`, …) — read best-effort, never blocks sign-in. |
| `source`               | `owner` (untokenized handle), `nft-holder` (tokenized handle held in its ATA), or `wallet` (address-control).       |

A bad proof returns `401 { "code": "CONTROL_PROOF_INVALID", … }`; a challenge bound to another audience returns `401 CHALLENGE_AUDIENCE_MISMATCH`; an expired or already-used challenge returns `401 CHALLENGE_EXPIRED` / `CHALLENGE_NOT_FOUND`.

## Sign in by transaction (`verify-tx`)

<Endpoint method="POST" path="/v1/auth/verify-tx" />

<Callout type="warn">
  **Use this path for Backpack, the X1 wallet, and Ledger.** Backpack's and the X1 wallet's **message** signatures do **not** verify on X1, so the `signMessage` → `/verify` path above fails control-proof for them. A Ledger signs messages, but wraps them in the Solana off-chain message envelope (SIMD-0048), so a signature over the raw `challenge` string doesn't verify on `/verify` either; for Ledger use this path or [SIWS](#sign-in-with-solana-verify-signin), which accepts the envelope. The fix is to prove control with a **transaction signature** instead: the wallet signs a memo-only transaction that carries the challenge, and the service verifies that signature. The transaction is **never broadcast**.
</Callout>

**1. Ask for a tx challenge.** Call `POST /v1/auth/challenge` with `mode: "tx"` and the user's `wallet`. The response is an address-control challenge plus a `memo` field — the exact string to embed:

```bash title='POST /v1/auth/challenge  {"wallet":"…","mode":"tx"}'
curl -X POST https://api.x1id.io/v1/auth/challenge \
  -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \
  -d '{ "wallet": "<WALLET>", "mode": "tx" }'
```

```json
{
  "nonce": "7f3a…",
  "handle": null,
  "challenge": "x1id-connect:v2:wallet-control:<audience>:<WALLET>:7f3a…",
  "audience": "<your key id>",
  "signature_encoding": "base58",
  "memo": "x1id-verify:<audience>:7f3a…",
  "expires_at": 1733270400,
  "proves": "wallet-control+attestations",
  "disclaimer": "…"
}
```

Embed `memo` exactly as returned. It is `x1id-verify:<audience>:<nonce>`; the unbound `x1id-verify:<nonce>` form is rejected.

**2. Build a memo-only transaction and sign it.** The transaction carries a single SPL-Memo instruction holding the `memo` string, with the wallet as the fee payer and only signer. It is never sent. Some wallets simulate a transaction before signing and refuse a placeholder blockhash, so use a recent one from your RPC:

```ts title="build + sign the memo transaction"
import { Connection, Transaction, TransactionInstruction, PublicKey } from "@solana/web3.js";

const MEMO_PROGRAM_ID = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");
const connection = new Connection("https://rpc.mainnet.x1.xyz");

const tx = new Transaction();
tx.feePayer = wallet.publicKey;                 // the wallet proving control
tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; // NEVER broadcast
tx.add(new TransactionInstruction({
  programId: MEMO_PROGRAM_ID,
  keys: [],
  data: Buffer.from(memo, "utf8"),              // the server's memo, verbatim
}));

const signed = await wallet.signTransaction(tx); // signTransaction, NOT signMessage
const signedTransaction = signed
  .serialize({ requireAllSignatures: false })
  .toString("base64");
```

**3. Verify.** Post the `nonce` and the base64 transaction back:

```bash title="POST /v1/auth/verify-tx (from your backend)"
curl -X POST https://api.x1id.io/v1/auth/verify-tx \
  -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \
  -d '{ "nonce": "7f3a…", "signedTransaction": "<base64 tx>" }'
```

The service requires exactly one instruction (the SPL-Memo carrying this challenge's memo), a normal recent-blockhash transaction (no durable nonce, no address lookup tables, no ComputeBudget instruction), and a signature from the challenged wallet. It then returns the **same** session body as [`/verify`](#verify). A bad proof returns `401 CONTROL_PROOF_INVALID`; an expired or already-used nonce returns `401 CHALLENGE_EXPIRED` / `CHALLENGE_NOT_FOUND`.

The SDK's `signInWithWallet` builds this transaction for you, with no `@solana/web3.js` dependency.

## Sign In With Solana (`verify-signin`)

<Endpoint method="POST" path="/v1/auth/verify-signin" />

For wallets that implement the Wallet Standard `solana:signIn` feature (SIWS). The wallet renders a readable sign-in message and signs it.

**1. Ask for a sign-in challenge** with `{ "wallet": "<address>", "mode": "signin" }`. Use its `nonce` in the SIWS input.

**2. Have the wallet sign in** with `solana:signIn`, passing your `domain`, the wallet `address`, a `statement`, `uri`, `version: "1"`, the challenge `nonce`, and `issuedAt`.

**3. Verify.** Post `nonce`, `signedMessage` (base64 of the exact bytes the wallet signed), and `signature` (the 64-byte ed25519 signature, base58; base64 is also accepted):

```bash title="POST /v1/auth/verify-signin (from your backend)"
curl -X POST https://api.x1id.io/v1/auth/verify-signin \
  -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \
  -d '{ "nonce": "7f3a…", "signedMessage": "<base64 bytes>", "signature": "<base58 sig>" }'
```

The server checks that line 1 is `<domain> wants you to sign in with your Solana account:` with an **allowed** domain, that line 2 is the challenged wallet, that a `Nonce:` line carries this challenge's nonce, and the ed25519 signature (over the raw bytes or the SIMD-0048 off-chain envelope). It returns the same session body as [`/verify`](#verify).

<Callout type="warn">
  **SIWS only works for allowlisted domains.** The SIWS domain must be on the deployment's allowlist. SIWS requires your domain on X1ID's allowlist (currently `developers.x1id.io` and `app.fortiblox.com`); ask X1ID to add yours. Any other domain is rejected with `401 CONTROL_PROOF_INVALID` ("… is not allowed here"). Otherwise, use the memo-transaction path of the SDK's [`signInWithWallet`](#with-the-sdk), which it falls back to when SIWS is rejected.
</Callout>

A challenge requested **without** `mode: "signin"` for a wallet that has a primary `@handle` is a handle-control challenge, which this endpoint rejects with `400 SIGNIN_PATH_HANDLE_UNSUPPORTED` — always request SIWS challenges with `mode: "signin"`.

## With the SDK

[`@x1id/resolve`](/docs/x1id/sdk) **0.21.0** ships the client side of all three paths.

<Callout type="warn">
  **Breaking: upgrade to 0.21.0.** The API now issues and accepts only the audience-bound strings above. `signinWithTx` / `buildVerifyTxChallenge` from SDK **0.20.x and earlier** throw on the bound memo, so transaction sign-in from those versions no longer works. Hand-rolled clients that sign the v1 `x1id-connect:v1:wallet-control:…` string or an unbound `x1id-verify:<nonce>` memo are rejected too. Upgrade with `npm install @x1id/resolve@0.21.0`.
</Callout>

**`signInWithWallet`** runs the full sign-in with fallbacks, in the order developers.x1id.io uses:

1. **SIWS** (`solana:signIn` → `/v1/auth/verify-signin`);
2. a signed, **never-sent memo transaction** (`solana:signTransaction` → `/v1/auth/verify-tx`) carrying the server's `x1id-verify:<audience>:<nonce>`;
3. **`signMessage`** over the server's `x1id-connect:v2:wallet-control:<audience>:<address>:<nonce>` (`/v1/auth/verify`).

It skips any path the wallet doesn't implement. A user rejection or a wallet timeout stops the chain, so the user is never prompted again after saying no; any other failure (including a SIWS domain rejection) moves on to the next path. It refuses to sign a memo or challenge that isn't audience-bound, and returns the session plus the `method` that succeeded.

**`signInWithSolana`** is SIWS only: it throws `SiwsError { code: "wallet-unsupported" }` for a wallet without `solana:signIn`, and `domain-not-allowed` when the domain isn't on the allowlist. Use it only on an allowlisted domain.

```ts title="browser"
import { signInWithWallet, SiwsError } from "@x1id/resolve";

try {
  const { method, ...session } = await signInWithWallet({
    wallet,                                         // a Wallet Standard wallet object
    apiBase: `${window.location.origin}/api/x1id`,  // YOUR backend proxy, not api.x1id.io
    apiKey: "server-held",                          // placeholder; your proxy replaces it
    rpcUrl: "https://rpc.mainnet.x1.xyz",           // recent blockhash for the memo transaction
    // Not on the SIWS allowlist? Skip the SIWS prompt the server would reject:
    methods: ["memo-tx", "sign-message"],
  });
  // session.token, session.address, session.handle; method = "siws" | "memo-tx" | "sign-message"
} catch (e) {
  if (e instanceof SiwsError) {
    // e.code: "wallet-unsupported" | "user-rejected" | "domain-not-allowed" | "nonce-expired"
    //       | "invalid-params" | "wallet-timeout" | "wallet-error" | "network" | "api-error"
  }
}
```

Without `methods`, a non-allowlisted domain still signs in — the SIWS attempt is rejected and the chain falls back to the memo transaction — but the user sees one extra wallet prompt. On `nonce-expired`, start the sign-in again.

The single steps are exported too (`requestConnectChallenge`, `requestSignInChallenge`, `buildSignInInput`, `buildSignInMemoTransaction`, `verifySolanaSignIn`, `verifySignInTransaction`, `verifySignInMessage`); every one takes the same `apiBase` / `apiKey` options.

## Keep your key on the server

The SDK helpers send `X-API-Key` on every call, so point them at **your** backend with `apiBase` and give them a placeholder key. Your backend forwards only the Connect sign-in operations to `api.x1id.io`, drops whatever key header the browser sent, and attaches the real key. This is how the X1ID demo dApp is built: browser → your `/api/…` → `api.x1id.io/v1/auth/*` with `X-API-Key` added server-side.

```ts title="app/api/x1id/v1/auth/[op]/route.ts (Next.js App Router)"
import { NextRequest, NextResponse } from "next/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// Only the sign-in operations — this must not be an open proxy.
const ALLOWED = new Set(["challenge", "verify", "verify-tx", "verify-signin"]);

export async function POST(req: NextRequest) {
  const op = req.nextUrl.pathname.split("/").pop() ?? "";
  if (!ALLOWED.has(op)) {
    return NextResponse.json({ code: "NOT_FOUND", message: "unsupported auth op" }, { status: 404 });
  }
  const body = await req.json().catch(() => null);
  if (!body || typeof body !== "object" || Array.isArray(body)) {
    return NextResponse.json({ code: "BAD_REQUEST", message: "body must be a JSON object" }, { status: 400 });
  }
  const res = await fetch(`https://api.x1id.io/v1/auth/${op}`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "X-API-Key": process.env.X1ID_API_KEY!, // server-side env only, never NEXT_PUBLIC_*
    },
    body: JSON.stringify(body),
    cache: "no-store",
  });
  // Pass the upstream body through as-is: an error from a proxy or CDN in front
  // of the API may not be JSON.
  const text = await res.text();
  return new NextResponse(text, {
    status: res.status,
    headers: { "content-type": res.headers.get("content-type") ?? "text/plain" },
  });
}
```

The browser code is the `signInWithWallet` call [above](#with-the-sdk), with `apiBase` set to your origin plus `/api/x1id` — the SDK appends `/v1/auth/<op>` to it. Read the session from your backend the same way: forward the user's `Authorization: Bearer <token>` to `GET /v1/auth/session` and add `X-API-Key` server-side. Rate-limit your proxy as you would any login endpoint; every forwarded call counts against your key's [limits](/docs/x1id/platform#tiers-and-rate-limits).

## Read the session

<Endpoint method="GET" path="/v1/auth/session" />

Validate a session and read who's signed in. The session is only valid to the app (key) it was issued to, and the handle and attestations are re-read live.

```bash title="GET /v1/auth/session (from your backend)"
curl https://api.x1id.io/v1/auth/session \
  -H "X-API-Key: $X1ID_API_KEY" \
  -H "Authorization: Bearer <token>"
# → { "handle": null, "address": "<WALLET>", "verified": [],
#     "source": "wallet", "proves": "wallet-control+attestations", "disclaimer": "…" }
# A wallet whose primary is @jack (an untokenized handle) would instead read:
#   { "handle": "jack", "address": "<WALLET>", "verified": ["x","github"], "source": "owner", … }
```

An absent, malformed, expired, revoked, or forged token returns `401 { "code": "INVALID_SESSION", … }`.

## Next

<Cards>
  <Card href="/docs/x1id/gate" icon="DoorClosed" title="Gate" description="Once signed in, gate on what the handle has verified — verified-GitHub-only, etc." />

  <Card href="/docs/x1id/verification" icon="BadgeCheck" title="Verified identity" description="What the `verified` platform list means and how to read it from chain yourself." />

  <Card href="/docs/x1id/platform" icon="Server" title="Developer platform" description="Get a key, the tiers, and the rest of the /v1 surface." />
</Cards>
