#
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.confor 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)
Resolved upstream
This bug has been fixed in the latest version of verus-typescript-primitives. If you're hitting Context.serialize is not a function, update the library:
yarn upgrade verus-typescript-primitives
# or
npm update verus-typescript-primitives
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
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 IGNOREfor 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:VRSCTESTorVRSC) - 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
- Verus RPC Documentation —
signmessage,verifymessage,getidentity - VerusCoin/verusid-ts-client — TypeScript library for VerusID Login Consent Protocol
- VerusCoin/verus-typescript-primitives — Core Verus types
- Verus Mobile — Mobile wallet with QR login support