Guide · 15 minutes · Node.js
Add Digi-ID login to a web app
Build an Express application that authenticates users via DigiByte wallet signatures. Eliminates stored password hashes, secrets, and credential databases in favor of verified public addresses.
How the pieces fit
- The browser asks your server for a challenge and shows it as a QR code.
- The wallet scans the QR code, signs the challenge string, and POSTs the payload directly to your server callback.
- The browser polls your server; once the callback is verified, the server upgrades the browser's session.
Because the wallet calls back from a different device, you link the two with the nonce: it is stored next to the browser's session ID when the challenge is created.
1. Set up the project
mkdir digiid-login && cd digiid-login
npm init -y && npm pkg set type=module
npm install express express-session digiid qrcodeThe wallet must reach your callback over HTTPS. For local testing, expose your dev server through an HTTPS tunnel (for example cloudflared tunnel --url http://localhost:3000) and use that hostname below.
2. Write the server
import crypto from 'node:crypto';
import express from 'express';
import session from 'express-session';
import DigiID from 'digiid';
const CALLBACK = process.env.DIGIID_CALLBACK; // e.g. "login.example.com/digiid/callback" (no scheme)
const TTL_MS = 90_000;
const pending = new Map(); // nonce -> { sessionId, expires, address? }
const app = express();
app.set('trust proxy', 1);
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: true,
cookie: { httpOnly: true, sameSite: 'lax', secure: true }
}));
app.use(express.static('public'));
// 1. Issue a challenge bound to this browser session.
app.post('/digiid/challenge', (req, res) => {
const nonce = crypto.randomBytes(16).toString('hex');
pending.set(nonce, { sessionId: req.sessionID, expires: Date.now() + TTL_MS });
res.json({ nonce, uri: new DigiID({ nonce, callback: CALLBACK }).uri });
});
// 2. The wallet posts its signature here.
app.post('/digiid/callback', express.json(), express.urlencoded({ extended: false }), (req, res) => {
const { address, uri, signature } = req.body ?? {};
if (!address || !uri || !signature) return res.status(400).json({ error: 'missing fields' });
const digiid = new DigiID({ address, uri, signature, callback: CALLBACK });
const challenge = pending.get(digiid.nonce);
if (!digiid.uriValid() || !challenge) return res.status(404).json({ error: 'unknown challenge' });
if (challenge.address) return res.status(409).json({ error: 'challenge already used' });
if (challenge.expires < Date.now()) return res.status(410).json({ error: 'challenge expired' });
if (!digiid.signatureValid()) return res.status(401).json({ error: 'invalid signature' });
challenge.address = address;
res.json({ message: 'Digi-ID verified' });
});
// 3. The browser polls until the wallet has called back.
app.get('/digiid/status', (req, res) => {
const nonce = String(req.query.nonce);
const challenge = pending.get(nonce);
if (!challenge || challenge.sessionId !== req.sessionID) return res.status(404).json({ state: 'unknown' });
if (!challenge.address) return res.json({ state: challenge.expires < Date.now() ? 'expired' : 'pending' });
pending.delete(nonce);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ state: 'error' });
req.session.address = challenge.address; // the user's Digi-ID for your site
res.json({ state: 'verified' });
});
});
app.get('/account', (req, res) => {
if (!req.session.address) return res.redirect('/');
res.send(`Signed in as ${req.session.address}`);
});
// Drop expired challenges.
setInterval(() => {
const now = Date.now();
for (const [nonce, c] of pending) if (c.expires + 60_000 < now) pending.delete(nonce);
}, 30_000).unref();
app.listen(3000, () => console.log('http://localhost:3000'));Only the browser session that created a challenge can read its status, and the session ID is regenerated on login to prevent session fixation.
3. Build the login page
<h1>Sign in with Digi-ID</h1>
<a id="digiid-link" href="#"><canvas id="digiid-qr" aria-label="Digi-ID QR code"></canvas></a>
<p id="digiid-status" role="status">Scan with your DigiByte wallet</p>
<script type="module" src="/login.js"></script>import QRCode from 'https://esm.sh/qrcode@1';
const status = document.querySelector('#digiid-status');
async function start() {
const { uri, nonce } = await fetch('/digiid/challenge', { method: 'POST' }).then((r) => r.json());
document.querySelector('#digiid-link').href = uri; // tap on mobile
await QRCode.toCanvas(document.querySelector('#digiid-qr'), uri, { width: 240 }); // scan on desktop
const poll = setInterval(async () => {
const { state } = await fetch(`/digiid/status?nonce=${nonce}`).then((r) => r.json());
if (state === 'verified') { clearInterval(poll); location.assign('/account'); }
if (state === 'expired') { clearInterval(poll); status.textContent = 'Expired, refreshing…'; start(); }
}, 2000);
}
start();In production, bundle qrcode with your app instead of loading it from a CDN.
4. Run it
DIGIID_CALLBACK=your-tunnel.example.com/digiid/callback \
SESSION_SECRET=$(node -e "console.log(crypto.randomBytes(32).toString('hex'))") \
node server.jsOpen your tunnel URL, scan the QR code with a Digi-ID-capable wallet and approve. You'll land on /account with your site-specific address.
5. Before you go live
- Store pending nonces in Redis or a database so active sessions persist across process restarts and load-balanced instances.
- Store users by address; let them link a second wallet as a recovery option.
- Rate-limit
/digiid/challengeand/digiid/callback. - Maintain a static callback domain: key derivation paths are tied strictly to the domain in the callback URL.
- Read Nonces & Replay Prevention.
- Need instant feedback instead of polling? See Cross-Device Polling vs. WebSockets.
Want to see the flow without a wallet first? Try the sandbox demo.