Sign in with X1ID
MainnetX1ID 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, exposed as an API.
Every Connect call is keyed — your X-API-Key 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.
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.
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 for the pattern, which works with the SDK helpers unchanged.
The flow
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.
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.
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.
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.
The quickest way to run all of this from a browser is the SDK's signInWithWallet, 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) |
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
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 (handleisnull) — the wallet proves control of itself, exactly as connecting a wallet is the identity on app.x1id.io."mode": "tx"(withwallet) — an address-control challenge plus amemofor the transaction path."mode": "signin"(withwallet) — an address-control challenge for Sign In With Solana.
tx and signin always prove wallet control, even when the wallet has a primary @handle; the session still reports that handle.
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{
"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
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.
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>" }'{
"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)
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, 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.
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:
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" }'{
"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:
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:
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. 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)
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):
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.
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, which it falls back to when SIWS is rejected.
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 0.21.0 ships the client side of all three paths.
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/[email protected].
signInWithWallet runs the full sign-in with fallbacks, in the order developers.x1id.io uses:
- SIWS (
solana:signIn→/v1/auth/verify-signin); - a signed, never-sent memo transaction (
solana:signTransaction→/v1/auth/verify-tx) carrying the server'sx1id-verify:<audience>:<nonce>; signMessageover the server'sx1id-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.
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.
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 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.
Read the 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.
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", … }.
