Guide · 15 minutes · Node.js
Cross-Device Polling vs. WebSockets
Digi-ID is a cross-device flow: the phone posts the signature to your callback, but the browser showing the QR code is the one that needs to sign in. Something has to tell the browser that the scan succeeded. You can ask repeatedly (polling) or be told (WebSockets).
This guide builds on Add Digi-ID login to a web app, which uses polling.
At a glance
| Short polling | WebSocket | |
|---|---|---|
| Delay after approval | Up to one interval (1–2 s) | Near-instant |
| Requests per login | ~45 at 2 s over a 90 s challenge | One upgrade, one message |
| Infrastructure | Any HTTP server, CDN or serverless platform | Long-lived connections; proxies must allow upgrades |
| Multiple instances | Works with any shared store (Redis, SQL) | Needs pub/sub so the instance that got the callback can reach the socket |
| Setting the session cookie | Directly in the status response | Not possible over the socket; needs a follow-up HTTP request |
Recommendation: start with polling. Move to WebSockets when instant feedback matters (kiosks, doors, checkout) or login volume makes polling traffic noticeable, and keep polling as the fallback.
1. Short polling
The browser asks /digiid/status every two seconds. When the callback has been verified, that same response regenerates the session and sets the cookie.
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); start(); }
}, 2000);- Keep the interval at 1–2 s. Faster adds load without a noticeable gain; slower feels broken.
- Stop polling when the tab is hidden (
document.visibilityState) and resume when it returns. - Rate-limit the status endpoint per session, not per IP, so shared networks aren't blocked.
2. WebSocket push
The browser opens a socket for its nonce. When the callback verifies the signature, the server pushes verified to that socket only.
import { WebSocketServer } from 'ws';
const sessionMiddleware = session({ /* same options as the web-app guide */ });
app.use(sessionMiddleware);
const server = app.listen(3000);
const wss = new WebSocketServer({ noServer: true });
const sockets = new Map(); // nonce -> ws
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `https://${req.headers.host}`);
if (url.pathname !== '/digiid/ws' || req.headers.origin !== process.env.SITE_ORIGIN) return socket.destroy();
sessionMiddleware(req, {}, () => {
const nonce = url.searchParams.get('nonce');
const challenge = pending.get(nonce);
if (!challenge || challenge.sessionId !== req.sessionID) return socket.destroy();
wss.handleUpgrade(req, socket, head, (ws) => {
sockets.set(nonce, ws);
ws.on('close', () => sockets.delete(nonce));
});
});
});
// In the callback handler, after the signature is verified:
challenge.address = address;
sockets.get(digiid.nonce)?.send(JSON.stringify({ state: 'verified' }));The push only says that the scan succeeded. The browser then calls the normal status endpoint, which regenerates the session and sets the cookie:
const ws = new WebSocket(`wss://${location.host}/digiid/ws?nonce=${nonce}`);
const expiry = setTimeout(() => { ws.close(); start(); }, 90_000);
ws.onmessage = async (event) => {
if (JSON.parse(event.data).state !== 'verified') return;
clearTimeout(expiry);
ws.close();
const { state } = await fetch(`/digiid/status?nonce=${nonce}`).then((r) => r.json());
if (state === 'verified') location.assign('/account');
};
ws.onerror = () => { clearTimeout(expiry); startPolling(nonce); }; // proxies and some networks block upgrades3. Running more than one server
The wallet's callback can land on a different instance from the one holding the browser's socket. Keep challenges in Redis or your database, and publish a message when a callback is verified:
// Callback instance
await redis.publish('digiid:verified', digiid.nonce);
// Every instance
subscriber.subscribe('digiid:verified', (nonce) => {
sockets.get(nonce)?.send(JSON.stringify({ state: 'verified' }));
});Polling needs only the shared store, which is one reason it is the simpler default.
Security checklist
- Bind to the session: only the browser session that created the challenge may poll it or open its socket.
- Check
Originon upgrade: WebSocket handshakes aren't covered by CORS, so reject unexpected origins to prevent cross-site socket hijacking. - Never push the address: send a state, and let the authenticated status request finish the sign-in.
- Close on expiry: drop sockets and polling when the challenge expires, then issue a new one.
- Use
wss://only in production, just as callbacks must use HTTPS.
Next: Nonces & Replay Prevention.