Add Digi-ID login to your app (server side)
Add Digi-ID login to your app (server side)
Digi-ID is passwordless login for anyone with a DigiByte wallet. Your server issues a one-time challenge, the wallet signs it with a key that never leaves the phone, and your server verifies the signature against the address the wallet claims. There is no password to store, nothing to phish, and nothing to leak. This guide walks through DigiScope's own implementation, step by step, so you can build the same flow in any stack.
Who this is for. You run a website or service and want "Sign in with your DigiByte wallet". You need a server that can keep a small table of challenges, verify a secp256k1 signature, and hand out sessions. The examples are Node and SQLite because that is what DigiScope runs; every step maps one-to-one onto other languages.
What you will build. An endpoint that mints challenges, a callback the wallet posts to, a status endpoint the browser polls, and a session table. Nothing here depends on DigiScope; your users' wallets talk only to your server.
Before you start
- A DigiByte address-and-signature verifier. DigiScope uses the
digiidnpm package (2014, tiny) which wrapsdigibyte-message, a fork of bitcore's message signing pinned to DigiByte's prefix. Any library that implements Bitcoin-style signed messages with the prefixDigiByte Signed Message:\nwill do. - A public https origin for the callback. Wallets rebuild the callback from the URI's host and path and default its scheme to https;
u=1marks a plain-http callback, and the DigiByte Android wallet refuses anyu=1callback outright, with no localhost exception. Test a local server with DigiByte Core'ssignmessagepluscurl(see Verify it); test with a phone only against a real https host. - A provisioning policy. DigiScope ships as a closed whitelist:
DIGIID_AUTO_PROVISIONdefaults tofalse, and only an address already linked to an account or listed inADMIN_DIGIID_ADDRESScan sign in. Your server can be open or closed; the callback below shows what the closed policy returns. - A database with two tables: challenges and sessions. The DigiScope schema is below.
Step 1 — Mint a challenge
A challenge is a random nonce wrapped in a digiid:// URI that points at your callback. DigiScope generates 16 random bytes, encodes them as 32 lowercase hex characters, and stores the exact URI it will show in the QR code, because the callback will later compare the wallet's submission against that stored string byte for byte.
from backend/src/services/digiid-service.js
// Generate random nonce (16 bytes = 32 hex chars)
const nonce = crypto.randomBytes(16).toString('hex');
// Build full callback URL (DigiID library expects full URL with protocol)
const callbackUrl = this.apiBaseUrl + this.callbackPath;
// Create Digi-ID URI
const digiid = new DigiID({
nonce,
callback: callbackUrl,
});
// Calculate expiry time
const expiresAt = new Date(Date.now() + this.challengeExpiryMinutes * 60 * 1000);
// …
return {
nonce,
uri: digiid.uri,
qrcode: digiid.qrcode,
expiresAt: expiresAt.toISOString(),
};
The resulting URI looks like digiid://your-app.example/api/auth/digiid/callback?x=6f1c…e2a9. Store it with status pending and a five-minute expiry. The qrcode field in DigiScope's response is a leftover from the 2014 library that points at a long-dead chart service; the browser renders its own QR from uri. Ignore it.
Trap: The callback origin must be your public https host.
API_BASE_URLcontributes only thehost/pathpart of the URI: the scheme is dropped, and DigiScope never sets the library'sunsecureflag, so nou=1is ever added and a wallet rebuilds the callback ashttps://<host>/<path>. A challenge minted againsthttp://localhost:3001is therefore usable only by thecurltest in Verify it; a phone would tryhttps://localhost:3001and fail on TLS.
The schema DigiScope uses:
from backend/src/models/schema-digiid.sql
CREATE TABLE IF NOT EXISTS digiid_challenges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nonce TEXT NOT NULL UNIQUE, -- Unique challenge nonce
callback_url TEXT NOT NULL, -- Callback URL for wallet
digiid_uri TEXT NOT NULL, -- Full Digi-ID URI (for QR code)
Step 2 — Show it to the user
On desktop, render uri as a QR code. On a phone, the same string works as a deep link: an <a href="digiid://…"> opens whichever wallet registered the digiid scheme. Start a countdown from expiresAt so the page can offer a fresh code when this one lapses, and begin polling the status endpoint (Step 6) every couple of seconds.
Step 3 — Receive the callback
The wallet POSTs JSON with three fields to the callback path from the URI: the URI exactly as scanned, the address it signed with, and the base64 signature.
{ "uri": "digiid://your-app.example/api/auth/digiid/callback?x=6f1c…e2a9",
"address": "D8m…kQ2",
"signature": "H2f…Ab0=" }
Answer 400 if any field is missing or is not a string. A JSON array or object in uri would otherwise reach the URL parser, and its exception text would leak into your error body. Everything else is verification.
Step 4 — Verify, in this order
DigiScope's verifier runs five checks, cheapest first, and consumes nothing until all five pass. The order matters: a garbage submission must never cost you an elliptic-curve operation, and a mismatch must never burn a real user's challenge.
from backend/src/services/digiid-service.js
// 1. Structural check (scheme + host + path + nonce present). Cheap, no DB.
if (!digiid.uriValid()) {
logger.warn('Digi-ID URI validation failed');
return { valid: false, error: 'Invalid URI' };
}
// …
if (typeof nonce !== 'string' || !/^[0-9a-f]{32}$/.test(nonce)) {
logger.warn('Digi-ID URI rejected: malformed nonce');
return { valid: false, error: 'Invalid URI' };
}
const challenge = db.prepare(`
SELECT * FROM digiid_challenges
WHERE nonce = ? AND status = 'pending'
`).get(nonce);
if (!challenge) {
logger.warn(`Challenge not found or already completed: ${nonce}`);
return { valid: false, error: 'Challenge not found or expired' };
}
// …
if (uri !== challenge.digiid_uri) {
// …
return { valid: false, error: 'Signed URI does not match the issued challenge' };
}
// 4. Signature over the (now known-identical) URI.
if (!digiid.signatureValid()) {
logger.warn('Digi-ID signature validation failed');
return { valid: false, error: 'Invalid signature' };
}
// 5. Expiry.
if (new Date(challenge.expires_at) < new Date()) {
db.prepare('UPDATE digiid_challenges SET status = ? WHERE id = ?')
.run('expired', challenge.id);
logger.warn(`Challenge expired: ${nonce}`);
return { valid: false, error: 'Challenge expired' };
}
- Structure. Scheme is
digiid:, host and path equal your callback, and anxparameter is present. The library does not check the port or extra parameters, which is why step 3 exists. - Nonce shape. 32 lowercase hex characters and a string. A duplicated
x=parameter parses as an array, and a newline in the nonce would reach your logs raw; reject both before they touch the database. - Byte-exact URI. The submitted URI must equal the stored one. This closes every normalisation trick: an appended parameter, a different port, uppercase in the host, encoded characters. It also means the message that was signed is provably the message you issued.
- Signature. Only now do the cryptography. The verifier recovers the public key from the 65-byte compact signature, derives a legacy P2PKH address, and requires it to equal the claimed address string, then checks the ECDSA signature over the double-SHA256 of the prefixed message.
- Expiry. Reject and mark
expiredif the row is past its time.
Only after all five does the row flip to completed, recording the address and signature:
from backend/src/services/digiid-service.js
db.prepare(`
UPDATE digiid_challenges
SET status = ?, digibyte_address = ?, signature = ?, completed_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run('completed', address, signature, challenge.id);
Security: Because the lookup filters on
status = 'pending'and the update flips it tocompleted, a second POST with the same nonce finds no row and gets 401. That is your replay protection. On a synchronous database driver this is already atomic; on an async driver make the update conditional (WHERE nonce = ? AND status = 'pending') and check that exactly one row changed.
Known limitation: The verifier derives a legacy
D…address from the recovered key and compares strings, so only legacy P2PKH addresses can log in. A wallet that reports a bech32dgb1q…address fails the equality check even with a valid signature. This is a property of thedigibyte-messageverifier, and DigiScope has kept it rather than widen the comparison.
Step 5 — Answer the wallet
The wallet that signed gets its own session in the callback response. DigiScope resolves the user by address, applying its own rules for who may sign in, and mints a 64-hex session token.
from backend/src/services/digiid-service.js
// Generate random session token (32 bytes = 64 hex chars)
const sessionToken = crypto.randomBytes(32).toString('hex');
// Calculate expiry
const expiresAt = new Date(Date.now() + this.sessionExpiryHours * 60 * 60 * 1000);
The 200 body is { success, sessionToken, expiresAt, user }. Treat that body as sensitive: it is a bearer token for the account bound to the signing address. Native apps that are both the browser and the wallet keep this token and never need the status endpoint at all.
Step 6 — Hand the browser exactly one session
The browser has been polling GET /status/:nonce. Once the row is completed, the poll must mint the browser's session exactly once. DigiScope resolves the user first, so a whitelist or rate-limit failure keeps the row completed and the same message repeats on the next poll instead of burning the challenge. Then it performs one atomic, expiry-bound update from completed to claimed and inserts the session inside the same transaction. The poll that wins gets the token; every later poll gets 409.
from backend/src/services/digiid-service.js
claimChallenge(nonce) {
const db = getDatabase();
// expires_at is stored as an ISO string (createChallenge) — compare against
// the same format, never against SQLite's datetime('now') text.
const result = db.prepare(`
UPDATE digiid_challenges
SET status = 'claimed'
WHERE nonce = ? AND status = 'completed' AND expires_at > ?
`).run(nonce, new Date().toISOString());
return result.changes === 1;
}
// …
claimAndCreateSession(nonce, userId, ipAddress, userAgent) {
const db = getDatabase();
return db.transaction(() => {
if (!this.claimChallenge(nonce)) return null;
return this.createSession(userId, ipAddress, userAgent);
})();
}
And the controller's ordering:
from backend/src/controllers/digiid-auth.js
if (status.status === 'claimed') {
return res.status(409).json(CLAIMED_RESPONSE);
}
// …
let user;
try {
user = await digiidService.getOrCreateUserByAddress(status.address, ipAddress);
} catch (error) {
return res.status(403).json({
success: false,
status: 'completed',
error: error.message,
});
}
// …
const userAgent = req.get('user-agent') || 'Unknown';
const session = digiidService.claimAndCreateSession(nonce, user.id, ipAddress, userAgent);
if (!session) {
return res.status(409).json(CLAIMED_RESPONSE);
}
Security: The status endpoint is a bearer-nonce endpoint. Anyone who can see the QR code holds the nonce and can poll it. Before the one-shot claim, every poller received a session; now there is one winner and the loser sees "already used", which is at least visible. DigiScope also logs a warning when the claiming poll comes from a different network than the browser that created the challenge, as a measurement before deciding whether to gate on it. Do not describe this endpoint as immune to QR observation, and know that the one-shot claim does nothing against a relayed QR: whoever minted the challenge holds the nonce and is the first poller, so if an attacker mints a challenge on your site and gets a victim to scan it, the attacker's browser receives the session. The wallet's domain check cannot catch this, because the domain really is yours. Mitigations are UX (the wallet shows the domain; your login page tells users to scan only a code shown on that domain) and, if you want more, binding the claim to the creating browser (for example an
HttpOnlycookie set when the challenge is created and required on the poll), which DigiScope does not do.
Step 7 — Authenticate later requests
DigiScope's middleware accepts either a session token or an API key on the Authorization: Bearer header and tells them apart by shape:
from backend/src/middleware/auth.js
const token = authHeader.substring(7); // Remove 'Bearer '
// Check if token is a session token (64 hex chars)
if (/^[a-f0-9]{64}$/i.test(token)) {
// Validate Digi-ID session token
const user = digiidService.validateSession(token);
Every failure returns the same generic 401 so a prober cannot learn whether a token was well-formed. Logout deletes the session row.
Step 8 — Sweep expired rows
Run a cleanup on a timer. DigiScope stores expires_at as ISO-8601 text and binds the same format when sweeping, because comparing ISO text against SQLite's own datetime('now') text sorts wrongly and silently never deletes same-day rows. That bug existed in production for months before the byte-exact review found it.
from backend/src/services/digiid-service.js
const nowIso = new Date().toISOString();
// Delete expired challenges (expires_at is ISO text — bind the same format)
const deletedChallenges = db.prepare(`
DELETE FROM digiid_challenges
WHERE expires_at < ?
`).run(nowIso);
from backend/src/utils/cleanup-scheduler.js
// Schedule hourly cleanup (every 60 minutes)
const intervalMs = 60 * 60 * 1000; // 1 hour
cleanupInterval = setInterval(runCleanup, intervalMs);
Rate limiting
Give the three login endpoints their own budget so a flood elsewhere cannot starve sign-in, and size it for polling: two-second polls for five minutes is about 150 requests per login attempt.
from backend/src/server.js
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // generous: Digi-ID status polling + session verifies + retries
Behind a reverse proxy, trust the forwarded address or every client shares one bucket.
The browser side, briefly
The poll loop treats any 4xx as the server's final answer for that challenge and stops, showing the server's message; network errors and 5xx keep polling. That is what turns a 403 "account disabled" or a 409 "already used" into something the user can read instead of an endless spinner.
from src/components/auth/digiIdPollData.js
export function classifyPollError(err) {
const status = Number(err?.status);
// No usable status (fetch rejected, DNS, offline, aborted) → transient.
if (!Number.isFinite(status)) {
return { terminal: false, kind: 'transient', message: null };
}
// 5xx and anything below 400 → server hiccup, worth retrying.
if (status < 400 || status >= 500) {
return { terminal: false, kind: 'transient', message: null };
}
if (status === 404) {
return { terminal: true, kind: 'expired', message: null };
}
On success, store the token and load the user's profile before flipping the app into its signed-in state, so nothing renders against an incomplete user object:
from src/contexts/AuthContext.jsx
const loginWithDigiID = async (sessionToken, userData) => {
localStorage.setItem('dgb_session_token', sessionToken);
localStorage.setItem('dgb_auth_method', 'digiid');
localStorage.removeItem('dgb_admin_api_key'); // Clear any old API key
Verify it
You do not need a phone to test the server. DigiByte Core can sign a message with the same prefix and header byte the verifier expects.
Decide who may sign in before you debug signatures. DigiScope ships as a closed whitelist:
DIGIID_AUTO_PROVISIONdefaults tofalse, and an address that is neither linked to an account nor listed inADMIN_DIGIID_ADDRESSgets403 { "success": false, "error": "Account not found. Please contact administrator for access." }from the callback even though its signature verified. For this test putDIGIID_AUTO_PROVISION=truein your local.env(listing the address inADMIN_DIGIID_ADDRESSalso works, but grants it the admin role). A 403 has already flipped the challenge row tocompleted, so after changing the environment restart and mint a fresh challenge.Start your server locally so it mints challenges against
http://localhost:3001. The URI it returns isdigiid://localhost:3001/api/auth/digiid/callback?x=<nonce>, with nou=1: thedigiidpackage only appends that flag when constructed withunsecure: true, whichcreateChallengenever does. Sign and post exactly theuristring the response gives you; the verifier compares it byte for byte.Mint a challenge and copy
urifrom the response:curl -s -X POST http://localhost:3001/api/auth/digiid/challengeSign it with a legacy address from a Core wallet.
signmessagerefuses bech32 addresses, which matches the verifier:digibyte-cli getnewaddress "" legacy # → D… digibyte-cli signmessage "D…" "digiid://localhost:3001/api/auth/digiid/callback?x=<nonce>"Post the callback exactly as a wallet would:
curl -s -X POST http://localhost:3001/api/auth/digiid/callback \ -H 'Content-Type: application/json' \ -d '{"uri":"digiid://localhost:3001/api/auth/digiid/callback?x=<nonce>","address":"D…","signature":"<base64>"}'Expected:
200 { "success": true, "sessionToken": "<64 hex>", … }. Post the same body again and expect401 { "success": false, "error": "Challenge not found or expired" }.Poll the status endpoint twice,
curl -s http://localhost:3001/api/auth/digiid/status/<nonce>: the first call returns 200 with a different session token, the second returns409 { "status": "claimed", … }.
Check: Mint a fresh challenge first (the one you just used is already
claimed, and the pending-status lookup runs before the URI compare, so it would answerChallenge not found or expiredinstead). Append&foo=1to the new URI, re-sign that exact string, and post it. You should get401 Signed URI does not match the issued challengeand the new row should still bepending.
Traps
- Wrong prefix. The message magic is
DigiByte Signed Message:\n, not Bitcoin's. A verifier built for Bitcoin will reject every DigiByte wallet. - Comparing addresses of different types. The verifier recovers a P2PKH address. A bech32 claim can never match it.
- Host and path only. The library's URI check ignores ports and extra parameters. Compare the whole string yourself.
- Moving the callback URL. Wallets that give each site its own identity (SLIP-0013, which the DigiByte Android wallet uses for every domain it has not already logged into with its legacy key) derive the user's key from the callback URL with the query stripped: scheme, host, port and path. Rename the path or change the host or port, and every such user signs in from a brand-new address and is locked out of the account bound to the old one. Treat the callback URL as permanent. If you must move it, keep the old URL answering for a migration window and let users link their new address while still signed in.
- Async drivers. Replay protection relies on lookup-then-update being atomic. Use a conditional update and check the change count.
- Timestamp formats. Store and compare expiry in one format. Mixed ISO and SQL text compares as strings and fails silently.
- Polling over the window. A local countdown that stops polling a second early can discard a legitimate success. Let a
completedresponse through even after the countdown ends. - Logging bodies. The response body carries a bearer token; the request body carries a signature that is a valid login until the nonce is spent. Log status codes, nonces and, on mismatch, the URI. Never the signature or the response body.
- Whitelist before you test. A verifier that passes still yields 403 if user resolution refuses the address. In DigiScope the callback has already marked the challenge
completedby then, so a retry of the same body returns401 Challenge not found or expired, which looks like a signature problem but is not. Decide your provisioning policy before debugging signatures.
Where DigiScope's implementation differs from the ideal
- Legacy-only addresses, as above.
- Session tokens are stored in plaintext with a fixed 24-hour expiry that does not slide.
- The status poll is protected by one-shot claiming and logging, not by binding the claim to the browser that created the challenge.
- The callback checks that the three fields are present, not that they are strings; a thrown library error's message is echoed in the 401 body.
- The browser keeps the session token in
localStorage, where any script on the origin can read it; anHttpOnly; Secure; SameSitecookie set by the status endpoint would remove that exposure at the cost of CSRF handling. - The 2014
digiidpackage depends on a git-pinned fork and exposes a deadqrcodeURL; a modern verifier would be a few dozen lines.
Next: the wallet side, in Handle digiid:// in your wallet.