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

PartRole
Door screenAny device with a browser in kiosk mode (a Raspberry Pi with a small display works well). Shows the current challenge.
Access serverIssues challenges per door, verifies callbacks, checks the allow-list, logs events.
Lock controllerA 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

schema.sql
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

access-server.js
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

door.html
<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.
Back to top