For dApp developers

Authentication and signing for XRPL dApps

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.

Why this matters

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.

Protocol spec

A VAULT_AUTH proof is a single XRPL transaction with these properties:

  • TransactionType = AccountSet (no flag changes)
  • Signed by the account it claims — multisig (tx.Signers non-empty) for vault proofs, single-sig for personal proofs
  • Result meta.TransactionResult === "tesSUCCESS"
  • Carries exactly one memo of type x-multi/auth

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

Quickstart with @x-multi/sdk

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/sdk

Use

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.

Sign individual transactions, cross-device

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.

Without the SDK — 3 steps

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.

1

Collect the proof hash

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-is

After 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 stored
2

Verify the proof

You 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 3

Path 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()
  }
}
3

Enforce one-shot sessions and issue your own cookie

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.

Security checklist

A correct integration MUST do all of these. The first four are load-bearing.

  • Verify meta.TransactionResult === "tesSUCCESS"
  • Require tx.Signers to be non-empty (quorum multisig)
  • Enforce session.domain matches your domain — otherwise proofs minted for another site can log in here
  • Enforce session.expires > now
  • Store session.session as one-shot — reject on replay
  • Optional but recommended: cross-check tx.Signers against the vault's current on-chain SignerList, so a proof made before a signer rotation can't be used after

Testing

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

Common questions

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.

Questions, feedback, or integration support

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.