For dApp developers
Two complementary protocols, one SDK. VAULT_AUTH mints on-chain sign-in proofs for both multisig vaults and personal r-addresses — quorum-signed for vaults, single-sig for personal. Sign requests let dApps ask personal users to sign individual transactions cross-device by scanning a QR with the X-Multi PWA. Pair them or use either alone.
Most XRPL dApps authenticate by asking a wallet to sign a nonce. Multisig accounts can't do that — there's no single signer. The current workaround is to set a regular key on the multisig account and use that regular key to log in. That reintroduces exactly the single point of failure multisig was meant to eliminate.
VAULT_AUTH replaces the regular-key hack with an on-chain proof: the signer quorum collaboratively signs a transaction whose memo contains session metadata, and that transaction's existence on-chain is itself the login credential. No key can be compromised to impersonate the vault — only a quorum-sized collusion of signers can.
A VAULT_AUTH proof is a single XRPL transaction with these properties:
TransactionType = AccountSet (no flag changes)tx.Signers non-empty) for vault proofs, single-sig for personal proofsmeta.TransactionResult === "tesSUCCESS"x-multi/authThe verifier returns accountType: 'vault' | 'personal' so dApps that want to allow only one don't have to inspect tx.Signers themselves.
The memo MemoData is hex-encoded JSON with this shape:
{
"session": "<uuid>", // one-shot session identifier
"domain": "<target domain>", // the dApp this proof is for
"created": "<ISO 8601 timestamp>",
"expires": "<ISO 8601 timestamp>"
}MemoType is also hex-encoded — x-multi/auth becomes 782D6D756C74692F61757468.
Three lines of code, drop-in browser SDK. Opens the X-Multi sign-in popup, runs the entire VAULT_AUTH flow, verifies the resulting on-chain proof, and resolves with a typed result your dApp can use directly. No manual hash handling, no XRPL client setup.
Install
npm install @x-multi/sdk
# or pnpm add @x-multi/sdk
# or yarn add @x-multi/sdkUse
import { signInWithXMulti, SignInError } from '@x-multi/sdk'
async function handleLogin() {
try {
const proof = await signInWithXMulti({ domain: window.location.hostname })
// proof.account — the authenticated XRPL r-address
// proof.accountType — 'vault' | 'personal'
// proof.signers — signer addresses (empty for personal proofs)
// proof.session — one-shot session ID (server-side replay guard)
// proof.txHash — on-chain proof transaction
await myExistingLoginEndpoint(proof.account, proof.session, proof.accountType)
} catch (e) {
if (e instanceof SignInError && e.code === 'popup_blocked') {
// fall back to redirect mode (see options below)
}
}
}
// To restrict to one account type (e.g. treasury-only dApps):
await signInWithXMulti({
domain: window.location.hostname,
restrictTo: 'vault', // X-Multi hides the personal option
})That's it on the client side. The SDK opens xmulti.app/sso in a popup, the user picks a vault and approves, signers complete the multisig signature, the proof transaction lands on chain, the SDK calls /api/verify/:txHash on your behalf, and resolves with the verified SignInResult.
Server-side: enforce one-shot sessions (in YOUR database)
The XRPL transaction backing each proof is public. Your backend must track each session ID and reject reuse — this is the only thing the dApp owner has to add beyond the client snippet. The table lives in your dApp's database, not X-Multi's — VAULT_AUTH is decentralised verification, so each relying party guards its own replay log. The cross-site attack (a proof minted for site A being redeemed at site B) is blocked separately by the domain check above, not by this table.
-- In your dApp's database (Postgres / Supabase shown):
CREATE TABLE used_xmulti_proofs (
session_id TEXT PRIMARY KEY,
tx_hash TEXT NOT NULL,
account TEXT NOT NULL,
account_type TEXT NOT NULL, -- 'vault' | 'personal'
domain TEXT NOT NULL,
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX used_xmulti_proofs_account_idx ON used_xmulti_proofs (account);// Inside your POST /api/auth/x-multi handler, after verifying the proof
// (either via /api/verify/:txHash or directly against XRPL):
const { error } = await db.from('used_xmulti_proofs').insert({
session_id: proof.session,
tx_hash: proof.txHash,
account: proof.account,
account_type: proof.accountType,
domain: proof.domain,
})
if (error?.code === '23505') {
// Postgres unique-violation = the session ID was already redeemed.
return res.status(409).json({ error: 'proof_already_used' })
}
// Otherwise issue your own session cookie keyed to proof.account.Content Security Policy — allowlist xmulti.app
If your dApp ships a Content-Security-Policyheader (most modern Next.js / Vercel sites do), the SDK's verify fetch will be silently blocked unless you allowlist X-Multi in connect-src. The failure is invisible from the dApp's side: the popup completes, the proof lands on chain, but the verify call never leaves the browser and the SDK throws verify_failed.
// Add to your CSP's connect-src directive:
"connect-src 'self' https://xmulti.app https://www.xmulti.app ..."(Both apex and www — xmulti.app issues a 307 redirect to www, so requests can resolve to either origin depending on configuration.) No script-src or frame-src changes are needed — the SDK is plain JS and opens the sign-in flow in a separate browser window, not an iframe.
You don't need to set up CORS on your side — X-Multi's verify endpoint returns Access-Control-Allow-Origin: * so any dApp origin can call it. Just open a request from your dApp and it works.
Redirect mode (popups blocked, in-app browsers, iframes)
For environments where popups don't open, pass popup: false and a returnUrl. The SDK redirects the entire page; X-Multi handles the proof; redirects back with ?proof=HASH&session=ID on your callback route. Verify it from the callback page with verifyProof():
// app/x-multi-callback/page.tsx
'use client'
import { verifyProof } from '@x-multi/sdk'
const params = new URLSearchParams(window.location.search)
const proof = await verifyProof(params.get('proof')!, {
domain: window.location.hostname,
})
await myLoginEndpoint(proof.account, proof.session, proof.accountType)Full options reference
signInWithXMulti({
domain: 'your-site.com', // required — embedded in proof, enforced server-side
restrictTo: undefined, // 'vault' | 'personal' | undefined (default: both allowed)
popup: true, // default — set false for redirect mode
popupSize: { w: 480, h: 720 }, // default
returnUrl: 'https://your-site.com/x-multi-callback', // required when popup: false
timeoutMs: 300_000, // default 5 min — bump for wide signer lists
})On failure the SDK throws a SignInError with a stablecode field. Codes: popup_blocked, popup_closed, timeout, session_mismatch, domain_mismatch, account_type_mismatch, verify_failed, expired, invalid_response, unknown.
VAULT_AUTH proves identity. Sign requests handle per-tx signing. Once a personal user is logged in (or even before — the two are independent), your dApp can ask them to sign any XRPL transaction by displaying a QR code they scan with the X-Multi PWA on their phone. No wallet extension needed, no Xaman dependency. Same protocol family, paired API.
Personal accounts only. Vault transactions still need quorum coordination through the proposal flow on xmulti.app/vaults. A common pattern: VAULT_AUTH for sign-in (vault or personal), then sign requests for per-tx signing once a personal user is logged in.
Use
import { requestSignature, SignRequestError } from '@x-multi/sdk'
import QRCode from 'qrcode'
async function payViaXMulti(userAddress, recipient, dropsAmount) {
const handle = await requestSignature({
domain: window.location.hostname,
sessionId: crypto.randomUUID(),
unsignedTx: {
TransactionType: 'Payment',
Account: userAddress,
Destination: recipient,
Amount: dropsAmount,
},
})
// Render handle.signUrl as a QR with your preferred lib
const dataUri = await QRCode.toDataURL(handle.signUrl, { width: 320, errorCorrectionLevel: 'H' })
document.getElementById('qr')!.innerHTML = `<img src="${dataUri}" />`
try {
const result = await handle.result
// result.txHash — on-chain hash of the signed + submitted tx
// result.signedBlob — hex blob (informational)
// result.sessionId — round-tripped from the request
// result.signedAt — ISO timestamp
return result.txHash
} catch (e) {
if (e instanceof SignRequestError) {
if (e.code === 'rejected') return null // user cancelled on phone
if (e.code === 'expired') throw new Error('Sign request timed out')
}
throw e
}
}The user opens the X-Multi PWA on their phone, taps the scan button in the top bar, points at the QR, reviews the transaction fields (with the requesting domain prominently displayed), and approves. The phone signs locally with the user's vault, submits to the XRPL, and posts the resulting tx hash back. Your handle.result promise resolves with that hash.
Full options reference
requestSignature({
domain: 'your-site.com', // required — shown to user on /sign page
sessionId: crypto.randomUUID(), // required — your replay token, echoed back
unsignedTx: { TransactionType: ..., Account: ..., ... }, // required
requiredAccount: undefined, // optional — pin to a specific signer r-address
ttlSeconds: 300, // default 5 min, range 30–3600
apiBase: undefined, // internal — only set for preview testing
})Handle shape
const handle = await requestSignature({...})
handle.id // server-assigned UUID
handle.signUrl // 'https://www.xmulti.app/sign/<id>' — encode this as a QR
handle.expiresAt // ISO timestamp
handle.sessionId // surfaced for symmetry with what you passed in
handle.result // Promise<{ txHash, signedBlob, sessionId, signedAt }>
handle.cancel() // stops the local poller; rejects result with 'rejected'On failure, handle.result rejects with a SignRequestError. Codes: invalid_options (validation), create_failed (server rejected the create call), rejected (user cancelled on phone, or you called handle.cancel()), expired (TTL elapsed before user signed), network (fetch failure), unknown.
Without the SDK
The same three endpoints are documented in the spec at /specs/xmulti-auth-v1.md — Protocol B section. POST /api/sign-request to create, GET /api/sign-request/[id]to poll, the X-Multi user's phone handles the rest.
For dApps that want full control of the integration without depending on the SDK npm package — paste-the-hash forms, custom popup management, no JS deps. This is the same protocol as the SDK, just exposed as raw building blocks.
Two options. Start with option A; add B later for a nicer UX.
A — paste. Add a "Log in with multisig vault" box to your login page that accepts a 64-character transaction hash. The user creates the proof in their vault tooling (e.g. X-Multi's /sso) and pastes the hash.
B — redirect.Your site sends the user to X-Multi's /sso page; X-Multi sends them back to your callback with the proof attached. Cleaner UX than paste, and the SDK wraps this same endpoint with popup management.
What you build (option B)
On the dApp side, generate a fresh random session ID, store it server-side keyed to the user, then redirect to the URL below — replacing the four bracketed placeholderswith your own values. Don't edit anything else.
https://xmulti.app/sso
?domain=<YOUR_DOMAIN> // e.g. sealcto.com — embedded in the proof memo
&session=<UUID_YOU_GENERATED> // crypto-random; you store this server-side
&return=<YOUR_CALLBACK_URL> // e.g. https://sealcto.com/x-multi-callback
&mode=redirect // literal — leave as-isAfter the user approves and signers complete the multisig signature, X-Multi appends two query params to your callback URL and redirects there:
<YOUR_CALLBACK_URL>?proof=<TX_HASH>&session=<SAME_UUID>
// proof — 64-char XRPL transaction hash (X-Multi fills this in)
// session — echoes back what you sent; verify it matches what you storedYou have two implementation paths. For production, use Path B — it removes any dependency on X-Multi infrastructure.
Path A — call our endpoint (fastest to integrate)
const res = await fetch(`https://xmulti.app/api/verify/${txHash}`)
const proof = await res.json()
if (!proof.verified) return reject('invalid proof')
if (proof.expired) return reject('proof expired')
if (proof.domain !== 'your-site.com') return reject('wrong domain')
// proof.vault_address is the authenticated identity
// proof.session is the one-shot session ID — see step 3Path B — verify directly against XRPL (no third-party trust)
import { Client } from 'xrpl'
async function verifyProof(txHash: string, expectedDomain: string) {
const client = new Client('wss://xrplcluster.com')
await client.connect()
try {
const res = await client.request({ command: 'tx', transaction: txHash, binary: false })
const tx = res.result as any
// 1. Transaction must have succeeded on-chain
if (tx.meta?.TransactionResult !== 'tesSUCCESS') throw new Error('tx failed')
// 2. Must be multisig-signed (proves quorum authority)
if (!Array.isArray(tx.Signers) || tx.Signers.length === 0) throw new Error('not multisig')
// 3. Find and decode the x-multi/auth memo
const hexToStr = (h: string) => Buffer.from(h, 'hex').toString('utf8')
const memo = (tx.Memos ?? []).find((m: any) =>
m.Memo?.MemoType && hexToStr(m.Memo.MemoType) === 'x-multi/auth'
)
if (!memo) throw new Error('no auth memo')
const session = JSON.parse(hexToStr(memo.Memo.MemoData))
// 4. Domain and expiry checks
if (session.domain !== expectedDomain) throw new Error('wrong domain')
if (new Date(session.expires) < new Date()) throw new Error('expired')
return {
vaultAddress: tx.Account as string,
sessionId: session.session as string,
signers: tx.Signers.map((s: any) => s.Signer.Account) as string[],
ledger: tx.ledger_index ?? tx.inLedger,
}
} finally {
await client.disconnect()
}
}The tx hash is public on XRPL — anyone who scrapes the chain can see it. To prevent replay, treat session.session as a one-shot token: accept it exactly once, then reject any later attempt to use it.
-- In YOUR dApp's database, not X-Multi's. Each relying party
-- keeps its own replay log; cross-site replay is blocked by the
-- domain check, not this table.
CREATE TABLE used_vault_proofs (
session_id TEXT PRIMARY KEY,
tx_hash TEXT NOT NULL,
vault_address TEXT NOT NULL,
domain TEXT NOT NULL,
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);After verification, insert the session ID (fail the login if the insert conflicts). Then issue your own session cookie / JWT tied to proof.vault_address— your dApp's existing session machinery takes over from here. VAULT_AUTH is only the login event; all subsequent requests use your own session.
A correct integration MUST do all of these. The first four are load-bearing.
meta.TransactionResult === "tesSUCCESS"tx.Signers to be non-empty (quorum multisig)session.domain matches your domain — otherwise proofs minted for another site can log in heresession.expires > nowsession.session as one-shot — reject on replaytx.Signers against the vault's current on-chain SignerList, so a proof made before a signer rotation can't be used afterTwo reference implementations are hosted under X-Multi:
/sdk-test — calls signInWithXMulti end-to-end with a real popup. Proves the SDK against your live vaults. Toggle SSO base between production and localhost.
/test-dapp — paste-the-hash dApp simulator. Calls only the public verify endpoint, gates protected content behind a valid proof.
To produce test proofs without the SDK, use any X-Multi vault: New Proposal → Prove Vault Identity (VAULT_AUTH), set target_domain to your staging domain, have signers approve.
Does this replace Xaman, GemWallet, or WalletConnect?
No. Those handle single-key wallets. VAULT_AUTH covers the case those don't: multisig vaults that have no master key and no regular key. Treat it as an additional login option alongside your existing wallet flows, not a replacement.
How fast is login?
One XRPL transaction — typically 4–8 seconds once the signer quorum approves it. Slower than a wallet signature but fine for actions a multisig vault does infrequently (admin changes, listing edits). Not the right tool for high-frequency login.
What does it cost the user?
The standard XRPL reserve-neutral fee of 0.0002 XRP per proof. Session reuse matters — after one VAULT_AUTH proof, your dApp should issue its own session token and stop re-verifying on every request.
What if the vault rotates signers after issuing a proof?
The old proof remains valid on-chain. If that matters to you, do the optional check in the security list — compare tx.Signersagainst the vault's current SignerList and reject proofs made by signers no longer on the list.
Can the X-Multi server lie about verification results?
Yes, in Path A. That's why we recommend Path B for production — verify against XRPL yourself. The endpoint at /api/verify/:txHash is a convenience for rapid prototyping, not a trust anchor.
This is a new pattern. If you hit something unclear, have a proposal for the memo schema, or want help integrating, open an issue or reach out. We'd rather evolve the spec with adopters than harden it prematurely.