Guide · 20 minutes · Node.js

Account Binding & Recovery

Most products can't drop passwords overnight. Let users add Digi-ID to the account they already have, sign in with it, and keep a second wallet as a recovery key. Passwords can then be retired one user at a time.

This guide builds on Add Digi-ID login to a web app; read that first for the challenge, callback and polling basics.

1. Store addresses, not secrets

A user can link several wallets. Each Digi-ID address belongs to exactly one account.

schema.sql
CREATE TABLE user_digiids (
  address     VARCHAR(40)  PRIMARY KEY,           -- site-specific Digi-ID address
  user_id     BIGINT       NOT NULL REFERENCES users(id),
  label       VARCHAR(60)  NOT NULL DEFAULT 'My wallet',
  created_at  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_used_at TIMESTAMP   NULL
);
CREATE INDEX user_digiids_user ON user_digiids (user_id);

2. Give every challenge a purpose

The same callback serves two flows. Record why the challenge was issued, and for linking, who asked for it:

server.js
app.post('/digiid/challenge', (req, res) => {
  const purpose = req.body?.purpose === 'link' ? 'link' : 'login';
  if (purpose === 'link' && !req.session.userId) return res.status(401).end();

  const nonce = crypto.randomBytes(16).toString('hex');
  pending.set(nonce, {
    purpose,
    sessionId: req.sessionID,
    userId: purpose === 'link' ? req.session.userId : null,
    expires: Date.now() + 90_000
  });
  res.json({ nonce, uri: new DigiID({ nonce, callback: CALLBACK }).uri });
});

The callback handler stays exactly as in the web-app guide: verify the URI and signature, then record challenge.address. The difference is what happens when the browser polls.

3. Finish the flow in the status handler

server.js
app.get('/digiid/status', async (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);

  const owner = await db.findUserIdByDigiId(challenge.address);

  if (challenge.purpose === 'link') {
    if (owner && owner !== challenge.userId) return res.json({ state: 'error', reason: 'already linked to another account' });
    if (!owner) await db.linkDigiId(challenge.userId, challenge.address);
    return res.json({ state: 'linked' });
  }

  if (!owner) return res.json({ state: 'error', reason: 'no account uses this wallet yet' });
  await db.touchDigiId(challenge.address);
  req.session.regenerate(() => {
    req.session.userId = owner;
    res.json({ state: 'verified' });
  });
});

4. Settings page: link, list, unlink

  • Link: a “Add Digi-ID” button requests a challenge with purpose: 'link' and shows the QR code.
  • List: show each linked wallet's label, the first and last characters of the address and when it was last used.
  • Unlink: require a fresh sign-in (password or another linked wallet) before removing an address, and never let a user remove their last way in.

5. Migrate away from passwords

  1. Show “Sign in with Digi-ID” next to the password form.
  2. After a password sign-in, suggest linking a wallet.
  3. Once two wallets are linked, offer to remove the password entirely.
  4. Keep your existing email-based recovery as a last resort, but require it to link a new wallet rather than set a password.

6. Second devices and recovery

  • Same wallet, new phone: restoring the wallet's recovery phrase on another device derives the same address for your site. Nothing changes on your side.
  • A different wallet: pair it as a second device by signing in with the first one and running the link flow from step 2. Both addresses now open the same account.
  • Lost device, phrase backed up: restore the wallet and sign in as before.
  • Lost device and phrase: sign in with another linked wallet and unlink the lost one. Only fall back to email recovery when no linked wallet is left, and have it end in linking a new wallet.
  • Notify on changes: email the user whenever a wallet is linked or unlinked, so an unexpected pairing is noticed quickly.

Pitfalls

  • Addresses are specific to your callback URL. Changing the URL (including its path) gives every user a new address and breaks their link — pick one and keep it.
  • Don't auto-create an account when an unknown address signs in during a link flow; that's how accounts get hijacked.
  • A lost recovery phrase means a lost key. Encourage a second wallet, and treat email recovery as high-risk.

Next: Nonces & Replay Prevention.

Back to top