Guide · 45 minutes · Node.js + any screen
Build a door-access prototype
A screen next to the door shows a Digi-ID QR code. Staff scan it, approve on their phone, and the door opens if their address is on the allow-list. No cards to issue, and access can be revoked instantly.
Architecture
| Part | Role |
|---|---|
| Door screen | Any device with a browser in kiosk mode (a Raspberry Pi with a small display works well). Shows the current challenge. |
| Access server | Issues challenges per door, verifies callbacks, checks the allow-list, logs events. |
| Lock controller | A relay or smart lock the server can trigger for a specific door. |
Use one callback for every door
Wallets derive the user's key from the callback URL including its path (see the key derivation and its test vector). If each door had its own callback path, the same person would have a different address at every door. Instead, use a single callback and remember which door each nonce belongs to.
1. Allow-list and audit log
CREATE TABLE door_access (
address VARCHAR(40) NOT NULL,
door_id VARCHAR(40) NOT NULL, -- or '*' for all doors
name VARCHAR(80) NOT NULL,
PRIMARY KEY (address, door_id)
);
CREATE TABLE door_events (
id BIGSERIAL PRIMARY KEY,
door_id VARCHAR(40) NOT NULL,
address VARCHAR(40) NOT NULL,
granted BOOLEAN NOT NULL,
at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);Enroll people by having them sign in once on your admin site with the same callback URL, then add their address to door_access.
2. Access server
import crypto from 'node:crypto';
import express from 'express';
import DigiID from 'digiid';
const CALLBACK = 'access.example.com/digiid/callback'; // one URL for all doors
const TTL_MS = 30_000; // short: codes can be photographed
const pending = new Map(); // nonce -> { doorId, expires, result? }
const current = new Map(); // doorId -> nonce shown on screen
const app = express();
// The door screen polls this: it gets a fresh challenge after expiry or after every scan.
app.get('/doors/:doorId/challenge', (req, res) => {
const { doorId } = req.params;
let nonce = current.get(doorId);
const active = nonce && pending.get(nonce);
if (!active || active.expires < Date.now() || active.result) {
if (active?.result) res.set('X-Last-Result', active.result);
nonce = crypto.randomBytes(16).toString('hex');
pending.set(nonce, { doorId, expires: Date.now() + TTL_MS });
current.set(doorId, nonce);
}
res.json({ uri: new DigiID({ nonce, callback: CALLBACK }).uri, expires: pending.get(nonce).expires });
});
app.post('/digiid/callback', express.json(), express.urlencoded({ extended: false }), async (req, res) => {
const { address, uri, signature } = req.body ?? {};
const digiid = new DigiID({ address, uri, signature, callback: CALLBACK });
const challenge = pending.get(digiid.nonce);
if (!digiid.uriValid() || !challenge || challenge.result || challenge.expires < Date.now()) {
return res.status(410).json({ error: 'expired, scan the new code' });
}
if (!digiid.signatureValid()) return res.status(401).json({ error: 'invalid signature' });
const granted = await db.hasAccess(address, challenge.doorId);
challenge.result = granted ? 'granted' : 'denied';
await db.logEvent(challenge.doorId, address, granted);
if (granted) await unlockDoor(challenge.doorId);
res.status(granted ? 200 : 403).json({ message: granted ? 'Door unlocked' : 'Access denied' });
});
async function unlockDoor(doorId) {
// Pulse the relay for this door, e.g. via GPIO, a smart-lock API or an MQTT message.
}
app.listen(443);3. Door screen
<main>
<h1>Scan to open — Server room</h1>
<canvas id="qr" aria-label="Digi-ID QR code"></canvas>
<p id="status" role="status"></p>
</main>
<script type="module">
import QRCode from '/vendor/qrcode.mjs';
const DOOR = 'server-room';
let shown = '';
async function refresh() {
const res = await fetch(`/doors/${DOOR}/challenge`);
const { uri } = await res.json();
const last = res.headers.get('X-Last-Result');
if (last) document.querySelector('#status').textContent = last === 'granted' ? 'Door open' : 'Access denied';
if (uri !== shown) { shown = uri; await QRCode.toCanvas(document.querySelector('#qr'), uri, { width: 360 }); }
}
refresh();
setInterval(refresh, 1500);
</script>Networking
The phone posts the signature directly to the access server, so it must reach the callback over HTTPS — via the internet or on-site Wi-Fi with a certificate the phone trusts. The door screen only needs to reach the access server.
Hardening checklist
- Keep challenges at 20–30 s and rotate after every scan.
- Put the lock controller on a separate network segment; only the access server may trigger it.
- Rate-limit the callback, and alert on repeated denied attempts.
- Decide what happens when the server is offline (fail secure vs. fail safe) with your safety officer.
- This is a prototype pattern — certified access-control hardware and fire codes still apply.