# VerusID Login: A Developer's Guide

How to implement passwordless authentication using VerusID signatures — from the developer who built it.


# Why This Guide Exists

I spent weeks getting VerusID login working across three different methods: CLI, GUI desktop, and Verus Mobile QR scanning. There's almost no documentation on this in the wild. This is the guide I wish I had.

VerusID login is passwordless, cryptographic authentication. Instead of "username + password," users prove identity by signing a challenge message with their VerusID's private key. No passwords stored, no password resets, no credential databases to breach.

The concept is simple. The implementation has sharp edges. This guide covers both.


# The Core Flow

Every VerusID login method follows the same pattern:

1. Server generates a random challenge (nonce + message)
2. User signs the challenge with their VerusID
3. Server verifies the signature against the claimed identity
4. If valid → create session

That's it. The complexity is in how the user signs (3 different methods) and the edge cases that'll break your implementation.


# Prerequisites

  • A running Verus daemon (verusd) — testnet recommended for development
  • RPC access configured (verus.conf or CLI flags)
  • The identity you're authenticating must exist on-chain

# RPC Setup

# Your verus.conf (or pass via CLI)
rpcuser=your_rpc_user
rpcpassword=your_rpc_password
rpcport=18843  # testnet
rpcallowip=127.0.0.1
// Basic RPC client
async function rpcCall(method: string, params: any[] = []) {
  const res = await fetch('http://127.0.0.1:18843', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Basic ' + Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString('base64'),
    },
    body: JSON.stringify({ method, params, id: Date.now() }),
  });
  const data = await res.json();
  if (data.error) throw new Error(data.error.message);
  return data.result;
}

# Method 1: CLI / GUI Console (Simplest)

This is the easiest to implement and the best starting point. The user runs a command in their Verus CLI or the GUI wallet's debug console.

# Server Side

import crypto from 'crypto';

// Step 1: Generate a challenge
function createChallenge(): { nonce: string; message: string } {
  const nonce = crypto.randomBytes(32).toString('hex');
  const message = `Login to My App | Nonce: ${nonce} | Timestamp: ${Date.now()}`;
  
  // Store nonce with expiry (5 minutes)
  nonceStore.set(nonce, { 
    createdAt: Date.now(), 
    expiresAt: Date.now() + 5 * 60 * 1000 
  });
  
  return { nonce, message };
}

// Step 3: Verify the signature
async function verifyLogin(
  verusId: string, 
  signature: string, 
  nonce: string
): Promise<boolean> {
  // Check nonce exists and hasn't expired
  const stored = nonceStore.get(nonce);
  if (!stored || stored.expiresAt < Date.now()) return false;
  
  // CRITICAL: Delete nonce immediately (one-time use)
  nonceStore.delete(nonce);
  
  // Reconstruct the original message
  const message = `Login to My App | Nonce: ${nonce} | Timestamp: ${stored.createdAt}`;
  
  // Verify via RPC
  const isValid = await rpcCall('verifymessage', [verusId, signature, message]);
  return isValid === true;
}

# What the User Does

You show them the message and they run:

# CLI
./verus -testnet signmessage "alice@" "Login to My App | Nonce: abc123... | Timestamp: 1770758507"

# Returns:
# {
#   "hash": "...",
#   "signature": "AZA4DgABQR+CJ2YF..."
# }

They paste back the signature value. Your server verifies it.

# Frontend Example

function LoginPage() {
  const [challenge, setChallenge] = useState(null);
  const [signature, setSignature] = useState('');
  const [verusId, setVerusId] = useState('');

  async function getChallenge() {
    const res = await fetch('/auth/challenge');
    setChallenge(await res.json());
  }

  async function submitLogin() {
    const res = await fetch('/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ 
        verusId, 
        signature, 
        nonce: challenge.nonce 
      }),
    });
    if (res.ok) window.location.reload();
  }

  return (
    <div>
      <button onClick={getChallenge}>Get Challenge</button>
      
      {challenge && (
        <>
          <p>Run this command:</p>
          <code>
            signmessage "{verusId || 'yourname@'}" "{challenge.message}"
          </code>
          
          <input 
            placeholder="Your VerusID (e.g. alice@)"
            value={verusId}
            onChange={e => setVerusId(e.target.value)}
          />
          <input 
            placeholder="Paste signature here"
            value={signature}
            onChange={e => setSignature(e.target.value)}
          />
          <button onClick={submitLogin}>Login</button>
        </>
      )}
    </div>
  );
}

# Method 2: Verus Mobile QR Login

This is the best UX but the hardest to implement. The user scans a QR code with Verus Mobile, approves the login, and they're authenticated. No copy-pasting.

# Architecture

Browser                    Your Server              Login Service            Verus Mobile
  │                           │                         │                       │
  │  1. GET /challenge ──────▶│                         │                       │
  │◀── QR code data ──────── │                         │                       │
  │                           │                         │                       │
  │  [User scans QR]          │                         │                       │
  │                           │                         │                 ◀─── [Scans QR]
  │                           │                         │◀── POST /verusidlogin │
  │                           │                         │    (signed response)  │
  │                           │                         │                       │
  │                           │                         │── Verify signature    │
  │                           │◀── POST /callback ──── │   (if valid)          │
  │                           │    { verified: true }   │                       │
  │                           │                         │                       │
  │  2. Poll /status ────────▶│                         │                       │
  │◀── { status: "completed" }│                         │                       │
  │                           │                         │                       │
  │  [Session cookie set]     │                         │                       │

# The Login Microservice

This is the piece that talks to Verus Mobile. It uses the verusid-ts-client library which handles the VerusID Login Consent Protocol.

Critical dependency note: You need the VerusCoin forks of these libraries, not the npm versions:

{
  "dependencies": {
    "verusid-ts-client": "github:VerusCoin/verusid-ts-client",
    "verus-typescript-primitives": "github:VerusCoin/verus-typescript-primitives",
    "@bitgo/utxo-lib": "github:AYCEchain/BitGoJS"
  }
}

Install with yarn, not npm. The GitHub dependencies have resolution issues with npm.

// login-server/src/index.js
import { LoginConsentProvisioningDecision } from 'verusid-ts-client';

const SIGNING_IADDRESS = process.env.SIGNING_IADDRESS; // Your server's identity i-address
const PRIVATE_KEY = process.env.PRIVATE_KEY;             // WIF private key for that identity
const CHAIN = process.env.CHAIN || 'VRSCTEST';
const CALLBACK_URL = process.env.SERVER_URL;             // Where Mobile sends the response

// Generate a login challenge
app.get('/login', (req, res) => {
  const challengeId = crypto.randomUUID();
  
  // Build the login consent request
  const request = new LoginConsentRequest({
    system_id: CHAIN,
    signing_id: SIGNING_IADDRESS,
    challenge: {
      challenge_id: challengeId,
      requested_access: [/* permissions */],
    },
    // Where Verus Mobile sends the signed response
    redirect_uris: [{
      type: 'callback',
      uri: `${CALLBACK_URL}/verusidlogin`,
    }],
  });
  
  // Sign the request with your server's private key
  const signed = request.sign(PRIVATE_KEY);
  
  // Build the deeplink URL that becomes the QR code
  const deeplink = `verus://verusid-login/${Buffer.from(JSON.stringify(signed)).toString('base64url')}`;
  
  // Store the challenge for later verification
  challenges.set(challengeId, { status: 'pending', createdAt: Date.now() });
  
  res.json({ challengeId, qrUrl: deeplink });
});

# The Context Bug (Fixed)

Older versions of verus-typescript-primitives had a bug in the Decision constructor — when deserializing a login response from Verus Mobile, it assigned the raw JSON object instead of constructing a proper Context instance:

// What older versions did:
this.context = decision.context;  // Raw JSON object — no serialize() method

// What current versions do (fixed):
this.context = decision.context ? new Context(decision.context.kv) : new Context();

If you're stuck on an older version for some reason, you can patch it manually:

import { Context } from 'verus-typescript-primitives';

function fixContext(decision) {
  if (decision.context && !(decision.context instanceof Context)) {
    const kv = decision.context.kv || decision.context;
    decision.context = new Context({ kv });
  }
  return decision;
}

But really, just update the library.

# Verifying the Mobile Response

When Verus Mobile sends the signed response to your callback:

app.post('/verusidlogin', async (req, res) => {
  const body = req.body;
  
  // The response may have Buffer objects serialized as JSON
  // { type: 'Buffer', data: [1, 2, 3] } → actual Buffer
  restoreBuffers(body);
  
  // Parse the provisioning decision
  const decision = new LoginConsentProvisioningDecision(body);
  
  // Verify the signature
  // This checks that the response was actually signed by the claimed identity
  const verificationResult = await decision.verify(CHAIN);
  
  if (!verificationResult.valid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  // Extract who signed
  const signingId = decision.signing_id; // i-address of the user
  
  // Notify your main server that this challenge was verified
  await fetch(`${YOUR_PLATFORM_URL}/auth/qr/callback`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      verified: true,
      challengeId: decision.challenge.challenge_id,
      signingId: signingId,
    }),
  });
  
  res.json({ success: true });
});

# Buffer Restoration Helper

Verus Mobile sends Buffers as JSON objects. You need to restore them:

function restoreBuffers(obj) {
  if (!obj || typeof obj !== 'object') return obj;
  
  if (obj.type === 'Buffer' && Array.isArray(obj.data)) {
    return Buffer.from(obj.data);
  }
  
  for (const key of Object.keys(obj)) {
    if (obj[key] && typeof obj[key] === 'object') {
      if (obj[key].type === 'Buffer' && Array.isArray(obj[key].data)) {
        obj[key] = Buffer.from(obj[key].data);
      } else {
        restoreBuffers(obj[key]);
      }
    }
  }
  
  return obj;
}

# Frontend: QR Display + Polling

function QRLogin() {
  const [qrUrl, setQrUrl] = useState(null);
  const [challengeId, setChallengeId] = useState(null);

  async function startLogin() {
    const res = await fetch('/auth/qr/challenge');
    const data = await res.json();
    setQrUrl(data.qrUrl);
    setChallengeId(data.challengeId);
  }

  // Poll for completion
  useEffect(() => {
    if (!challengeId) return;
    const interval = setInterval(async () => {
      const res = await fetch(`/auth/qr/status/${challengeId}`);
      const data = await res.json();
      if (data.status === 'completed') {
        clearInterval(interval);
        window.location.reload(); // Session cookie is now set
      }
    }, 2000);
    return () => clearInterval(interval);
  }, [challengeId]);

  return (
    <div>
      <button onClick={startLogin}>Login with Verus Mobile</button>
      {qrUrl && (
        <QRCode value={qrUrl} size={256} />
      )}
    </div>
  );
}

# Method 3: Signing for Actions (Not Just Login)

Once authenticated, you'll likely need users to sign specific actions — job requests, deliveries, reviews. This is different from login: the message content matters.

# Message Format: Pipe-Delimited Single Line

This is critical. I originally used multi-line messages with \n newlines. This works in the CLI but breaks in the Verus GUI debug console — the console doesn't support $'...\n...' shell syntax.

Use pipe-delimited single-line format:

// ✅ Works everywhere — CLI, GUI console, Verus Mobile
function generateJobRequestMessage(seller, description, amount, currency, deadline, timestamp) {
  return `VAP-JOB|To:${seller}|Desc:${description}|Amt:${amount} ${currency}|Deadline:${deadline || 'None'}|Ts:${timestamp}|I request this job and agree to pay upon completion.`;
}

The user runs:

signmessage "alice@" "VAP-JOB|To:bob@|Desc:Code review|Amt:50 VRSC|Deadline:2026-02-15|Ts:1770758507|I request this job and agree to pay upon completion."

Simple double quotes. Works in CLI, GUI console, and can be constructed programmatically by Verus Mobile or agent SDKs.

# Verification: Reconstruct, Don't Trust

Never verify the raw message the user claims they signed. Always reconstruct the expected message from the submitted parameters and verify against that:

// User submits: { seller, description, amount, timestamp, signature }

// WRONG — trusting user's claimed message
const isValid = await rpcCall('verifymessage', [userId, signature, userProvidedMessage]);

// RIGHT — reconstruct the expected message from submitted params
const expectedMessage = generateJobRequestMessage(seller, description, amount, currency, deadline, timestamp);
const isValid = await rpcCall('verifymessage', [userId, signature, expectedMessage]);

This prevents an attacker from signing a different message (e.g., different amount) and submitting it with modified parameters.


# Identity Resolution: Names vs i-Addresses

VerusIDs have two forms:

  • Friendly name: alice@, alice.yourapp@
  • i-address: i... (34-character base58 address)

# What Works Where

Operation Friendly Name i-Address
signmessage (won't find private key)
verifymessage
getidentity

Key insight: signmessage needs the friendly name because it looks up the private key in the local wallet. verifymessage accepts both because it only needs the public key (which it resolves from the blockchain).

# Use fullyqualifiedname

When you resolve an identity via RPC, use fullyqualifiedname (not identity.name):

const identity = await rpcCall('getidentity', ['alice@']);

// identity.fullyqualifiedname = "alice.VRSCTEST@"  ← Use this
// identity.identity.name = "alice"                   ← Don't use this (ambiguous)
// identity.identity.identityaddress = "iAddR..."     ← Store this as DB key

// Strip the chain suffix for display
const displayName = identity.fullyqualifiedname
  .replace(/\.VRSCTEST@$/, '')
  .replace(/\.VRSC@$/, '');
// "alice" or "alice.yourapp"

# SubID Gotcha

If your identity is a subID (like alice.yourapp@), you cannot use the parent path for signing:

# ✅ Works — the actual registered identity
signmessage "alice@" "hello"

# ❌ FAILS — "Invalid identity"  
signmessage "alice.yourapp@" "hello"
# (unless alice.yourapp is a separately registered identity with its own keys)

SubIDs under a namespace share the namespace's identity structure but signing requires the identity that actually holds the private key in your wallet.


# Security Checklist

Things I learned the hard way:

  • Nonces must be one-time use — Delete immediately after verification, not after expiry
  • Nonces must expire — 5 minutes is reasonable. 24 hours is way too long.
  • Use INSERT OR IGNORE for nonce claiming — prevents race conditions where two requests verify the same nonce
  • Timestamp in the challenge message — Prevents a signed challenge from being replayed after the nonce is regenerated
  • Verify the reconstructed message — Never trust a user-provided message string
  • Store i-addresses in your DB — They're immutable. Friendly names can be transferred.
  • Authenticate your callback endpoint — If you have a separate login microservice forwarding verified results, authenticate that channel (HMAC secret or localhost-only). Otherwise anyone can POST { verified: true } and hijack sessions.
  • HttpOnly + Secure + SameSite=Strict on session cookies
  • Session lifetime: 1 hour with sliding window (extend on activity)

# Common Errors and Fixes

# "Invalid identity" on signmessage

The identity doesn't exist in your wallet or you're using the wrong name format. Try listidentities to see what's available.

# Signature verification returns false but signature looks valid

The message you're verifying against doesn't exactly match what was signed. Even a single character difference (trailing space, different newline) causes failure. Log both the signed message and the verification message and diff them.

# Verus Mobile shows "Invalid Request" on QR scan

Your login consent request is malformed. Common causes:

  • Wrong system_id (must match the chain: VRSCTEST or VRSC)
  • Invalid signing_id (must be a valid i-address)
  • Callback URL not reachable from the phone's network

# Login works locally but not in production

Your callback URL must be reachable from Verus Mobile (which runs on the user's phone). If your server is behind a firewall, you need a tunnel (Cloudflare Tunnel, ngrok, etc.) or a public-facing callback endpoint.

# Context.serialize is not a function

You're on an older version of verus-typescript-primitives. Update the library — the bug has been fixed upstream. See "The Context Bug (Fixed)" section above.


# Full Working Example

A minimal but complete VerusID login server:

// server.ts — Minimal VerusID CLI/GUI login
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import crypto from 'crypto';

const app = Fastify();
app.register(cookie);

const RPC_URL = 'http://127.0.0.1:18843';
const RPC_AUTH = Buffer.from('user:pass').toString('base64');
const nonces = new Map<string, { message: string; expiresAt: number }>();

// Cleanup expired nonces every minute
setInterval(() => {
  const now = Date.now();
  for (const [k, v] of nonces) {
    if (v.expiresAt < now) nonces.delete(k);
  }
}, 60000);

async function verusRpc(method: string, params: any[]) {
  const res = await fetch(RPC_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Basic ${RPC_AUTH}` },
    body: JSON.stringify({ method, params, id: 1 }),
  });
  const data = await res.json();
  if (data.error) throw new Error(data.error.message);
  return data.result;
}

// GET /challenge — Generate a login challenge
app.get('/challenge', async () => {
  const nonce = crypto.randomBytes(32).toString('hex');
  const message = `Sign to login | ${nonce} | ${Date.now()}`;
  nonces.set(nonce, { message, expiresAt: Date.now() + 300000 });
  return { nonce, message };
});

// POST /login — Verify signature and create session
app.post('/login', async (req, reply) => {
  const { verusId, signature, nonce } = req.body as any;
  
  const stored = nonces.get(nonce);
  if (!stored || stored.expiresAt < Date.now()) {
    return reply.code(401).send({ error: 'Invalid or expired challenge' });
  }
  nonces.delete(nonce); // One-time use
  
  const isValid = await verusRpc('verifymessage', [verusId, signature, stored.message]);
  if (!isValid) {
    return reply.code(401).send({ error: 'Invalid signature' });
  }
  
  // Resolve identity
  const identity = await verusRpc('getidentity', [verusId]);
  const iAddress = identity.identity.identityaddress;
  
  // Create session
  const sessionId = crypto.randomBytes(32).toString('hex');
  // Store session in your DB...
  
  reply.setCookie('session', sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 3600,
    path: '/',
  });
  
  return { success: true, identity: iAddress };
});

app.listen({ port: 3000 });

# Resources

See something wrong? Select text and tap here to suggest an edit.

Suggest an Edit

* Select text on the page before opening this panel to auto-fill your edit
0 / 10,000