Add Digi-ID login to your app (server side)

Last verified against DigiByte Core v9.26.5

Add Digi-ID login to your app (server side)

Digi-ID is passwordless login for anyone with a DigiByte wallet. Your server issues a one-time challenge, the wallet signs it with a key that never leaves the phone, and your server verifies the signature against the address the wallet claims. There is no password to store, nothing to phish, and nothing to leak. This guide walks through DigiScope's own implementation, step by step, so you can build the same flow in any stack.

Who this is for. You run a website or service and want "Sign in with your DigiByte wallet". You need a server that can keep a small table of challenges, verify a secp256k1 signature, and hand out sessions. The examples are Node and SQLite because that is what DigiScope runs; every step maps one-to-one onto other languages.

What you will build. An endpoint that mints challenges, a callback the wallet posts to, a status endpoint the browser polls, and a session table. Nothing here depends on DigiScope; your users' wallets talk only to your server.

Before you start

Digi-ID login sequence: browser, server, wallet The browser asks the server for a challenge and renders it as a QR code, then polls. The wallet scans, signs the URI, and posts the signature to the callback. The server verifies, marks the challenge completed, answers the wallet, and hands the browser one session on its next poll; later polls get 409. Browser Your server Wallet 1 POST /api/auth/digiid/challenge 2 { nonce, uri, expiresAt } → render QR / deep link GET /status/:nonce every 2 s → pending 3 user scans the QR (or taps the digiid:// link) 4 sign the exact URI key stays on the phone 5 POST /callback { uri, address, signature } 6 uriValid → lookup nonce byte-exact uri → ECDSA expiry → status = completed reject → 401, nothing consumed 7 200 { sessionToken } — the wallet's own session 8 GET /status/:nonce → completed 9 claim once → 200 { sessionToken }; later polls → 409
The whole flow. The wallet never sends a secret; it sends a signature over a string your server generated.

Step 1 — Mint a challenge

A challenge is a random nonce wrapped in a digiid:// URI that points at your callback. DigiScope generates 16 random bytes, encodes them as 32 lowercase hex characters, and stores the exact URI it will show in the QR code, because the callback will later compare the wallet's submission against that stored string byte for byte.

from backend/src/services/digiid-service.js

      // Generate random nonce (16 bytes = 32 hex chars)
      const nonce = crypto.randomBytes(16).toString('hex');

      // Build full callback URL (DigiID library expects full URL with protocol)
      const callbackUrl = this.apiBaseUrl + this.callbackPath;

      // Create Digi-ID URI
      const digiid = new DigiID({
        nonce,
        callback: callbackUrl,
      });

      // Calculate expiry time
      const expiresAt = new Date(Date.now() + this.challengeExpiryMinutes * 60 * 1000);
// …
      return {
        nonce,
        uri: digiid.uri,
        qrcode: digiid.qrcode,
        expiresAt: expiresAt.toISOString(),
      };

The resulting URI looks like digiid://your-app.example/api/auth/digiid/callback?x=6f1c…e2a9. Store it with status pending and a five-minute expiry. The qrcode field in DigiScope's response is a leftover from the 2014 library that points at a long-dead chart service; the browser renders its own QR from uri. Ignore it.

Trap: The callback origin must be your public https host. API_BASE_URL contributes only the host/path part of the URI: the scheme is dropped, and DigiScope never sets the library's unsecure flag, so no u=1 is ever added and a wallet rebuilds the callback as https://<host>/<path>. A challenge minted against http://localhost:3001 is therefore usable only by the curl test in Verify it; a phone would try https://localhost:3001 and fail on TLS.

The schema DigiScope uses:

from backend/src/models/schema-digiid.sql

CREATE TABLE IF NOT EXISTS digiid_challenges (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  nonce TEXT NOT NULL UNIQUE,           -- Unique challenge nonce
  callback_url TEXT NOT NULL,           -- Callback URL for wallet
  digiid_uri TEXT NOT NULL,             -- Full Digi-ID URI (for QR code)

Step 2 — Show it to the user

On desktop, render uri as a QR code. On a phone, the same string works as a deep link: an <a href="digiid://…"> opens whichever wallet registered the digiid scheme. Start a countdown from expiresAt so the page can offer a fresh code when this one lapses, and begin polling the status endpoint (Step 6) every couple of seconds.

Step 3 — Receive the callback

The wallet POSTs JSON with three fields to the callback path from the URI: the URI exactly as scanned, the address it signed with, and the base64 signature.

{ "uri": "digiid://your-app.example/api/auth/digiid/callback?x=6f1c…e2a9",
  "address": "D8m…kQ2",
  "signature": "H2f…Ab0=" }

Answer 400 if any field is missing or is not a string. A JSON array or object in uri would otherwise reach the URL parser, and its exception text would leak into your error body. Everything else is verification.

Step 4 — Verify, in this order

DigiScope's verifier runs five checks, cheapest first, and consumes nothing until all five pass. The order matters: a garbage submission must never cost you an elliptic-curve operation, and a mismatch must never burn a real user's challenge.

from backend/src/services/digiid-service.js

      // 1. Structural check (scheme + host + path + nonce present). Cheap, no DB.
      if (!digiid.uriValid()) {
        logger.warn('Digi-ID URI validation failed');
        return { valid: false, error: 'Invalid URI' };
      }
// …
      if (typeof nonce !== 'string' || !/^[0-9a-f]{32}$/.test(nonce)) {
        logger.warn('Digi-ID URI rejected: malformed nonce');
        return { valid: false, error: 'Invalid URI' };
      }

      const challenge = db.prepare(`
        SELECT * FROM digiid_challenges
        WHERE nonce = ? AND status = 'pending'
      `).get(nonce);

      if (!challenge) {
        logger.warn(`Challenge not found or already completed: ${nonce}`);
        return { valid: false, error: 'Challenge not found or expired' };
      }
// …
      if (uri !== challenge.digiid_uri) {
// …
        return { valid: false, error: 'Signed URI does not match the issued challenge' };
      }

      // 4. Signature over the (now known-identical) URI.
      if (!digiid.signatureValid()) {
        logger.warn('Digi-ID signature validation failed');
        return { valid: false, error: 'Invalid signature' };
      }

      // 5. Expiry.
      if (new Date(challenge.expires_at) < new Date()) {
        db.prepare('UPDATE digiid_challenges SET status = ? WHERE id = ?')
          .run('expired', challenge.id);
        logger.warn(`Challenge expired: ${nonce}`);
        return { valid: false, error: 'Challenge expired' };
      }
  1. Structure. Scheme is digiid:, host and path equal your callback, and an x parameter is present. The library does not check the port or extra parameters, which is why step 3 exists.
  2. Nonce shape. 32 lowercase hex characters and a string. A duplicated x= parameter parses as an array, and a newline in the nonce would reach your logs raw; reject both before they touch the database.
  3. Byte-exact URI. The submitted URI must equal the stored one. This closes every normalisation trick: an appended parameter, a different port, uppercase in the host, encoded characters. It also means the message that was signed is provably the message you issued.
  4. Signature. Only now do the cryptography. The verifier recovers the public key from the 65-byte compact signature, derives a legacy P2PKH address, and requires it to equal the claimed address string, then checks the ECDSA signature over the double-SHA256 of the prefixed message.
  5. Expiry. Reject and mark expired if the row is past its time.

Only after all five does the row flip to completed, recording the address and signature:

from backend/src/services/digiid-service.js

      db.prepare(`
        UPDATE digiid_challenges
        SET status = ?, digibyte_address = ?, signature = ?, completed_at = CURRENT_TIMESTAMP
        WHERE id = ?
      `).run('completed', address, signature, challenge.id);
What the wallet actually signs The message is the DigiByte signed-message prefix, a varint length, and the raw URI bytes. It is hashed with double SHA-256 and signed with ECDSA into a 65-byte compact recoverable signature, which travels as base64. payload bytes 0x19 len 25 "DigiByte Signed Message:\n" not Bitcoin's prefix varint(len) 1 byte below 253 digiid://host/path?x=<nonce> the exact URI string, byte for byte SHA256( SHA256( payload ) ) ECDSA sign (secp256k1) 65-byte compact signature header ‖ r ‖ s → base64 (88 chars) The verifier recovers the public key from the signature, derives a legacy P2PKH address, and requires it to equal the claimed address.
The signed message. Note the DigiByte prefix, the varint length, and that the URI is signed as-is.

Security: Because the lookup filters on status = 'pending' and the update flips it to completed, a second POST with the same nonce finds no row and gets 401. That is your replay protection. On a synchronous database driver this is already atomic; on an async driver make the update conditional (WHERE nonce = ? AND status = 'pending') and check that exactly one row changed.

Known limitation: The verifier derives a legacy D… address from the recovered key and compares strings, so only legacy P2PKH addresses can log in. A wallet that reports a bech32 dgb1q… address fails the equality check even with a valid signature. This is a property of the digibyte-message verifier, and DigiScope has kept it rather than widen the comparison.

Step 5 — Answer the wallet

The wallet that signed gets its own session in the callback response. DigiScope resolves the user by address, applying its own rules for who may sign in, and mints a 64-hex session token.

from backend/src/services/digiid-service.js

      // Generate random session token (32 bytes = 64 hex chars)
      const sessionToken = crypto.randomBytes(32).toString('hex');

      // Calculate expiry
      const expiresAt = new Date(Date.now() + this.sessionExpiryHours * 60 * 60 * 1000);

The 200 body is { success, sessionToken, expiresAt, user }. Treat that body as sensitive: it is a bearer token for the account bound to the signing address. Native apps that are both the browser and the wallet keep this token and never need the status endpoint at all.

Step 6 — Hand the browser exactly one session

The browser has been polling GET /status/:nonce. Once the row is completed, the poll must mint the browser's session exactly once. DigiScope resolves the user first, so a whitelist or rate-limit failure keeps the row completed and the same message repeats on the next poll instead of burning the challenge. Then it performs one atomic, expiry-bound update from completed to claimed and inserts the session inside the same transaction. The poll that wins gets the token; every later poll gets 409.

from backend/src/services/digiid-service.js

  claimChallenge(nonce) {
    const db = getDatabase();
    // expires_at is stored as an ISO string (createChallenge) — compare against
    // the same format, never against SQLite's datetime('now') text.
    const result = db.prepare(`
      UPDATE digiid_challenges
      SET status = 'claimed'
      WHERE nonce = ? AND status = 'completed' AND expires_at > ?
    `).run(nonce, new Date().toISOString());
    return result.changes === 1;
  }
// …
  claimAndCreateSession(nonce, userId, ipAddress, userAgent) {
    const db = getDatabase();
    return db.transaction(() => {
      if (!this.claimChallenge(nonce)) return null;
      return this.createSession(userId, ipAddress, userAgent);
    })();
  }

And the controller's ordering:

from backend/src/controllers/digiid-auth.js

    if (status.status === 'claimed') {
      return res.status(409).json(CLAIMED_RESPONSE);
    }
// …
      let user;
      try {
        user = await digiidService.getOrCreateUserByAddress(status.address, ipAddress);
      } catch (error) {
        return res.status(403).json({
          success: false,
          status: 'completed',
          error: error.message,
        });
      }
// …
      const userAgent = req.get('user-agent') || 'Unknown';
      const session = digiidService.claimAndCreateSession(nonce, user.id, ipAddress, userAgent);
      if (!session) {
        return res.status(409).json(CLAIMED_RESPONSE);
      }
Challenge row states A challenge is created pending. A valid callback makes it completed. The first browser poll that wins the atomic claim makes it claimed and mints the session. Pending or completed rows past their expiry become expired. Every row is deleted by the hourly cleanup once expired. pending completed claimed expired valid callback first poll wins the claim UPDATE … WHERE status='completed' AND expires_at > now 5 min passed nobody claimed in time later polls → 409 callback replay → 401 hourly cleanup deletes any row whose expires_at (ISO text) is in the past
The four states a challenge row moves through, and what each later request receives.

Security: The status endpoint is a bearer-nonce endpoint. Anyone who can see the QR code holds the nonce and can poll it. Before the one-shot claim, every poller received a session; now there is one winner and the loser sees "already used", which is at least visible. DigiScope also logs a warning when the claiming poll comes from a different network than the browser that created the challenge, as a measurement before deciding whether to gate on it. Do not describe this endpoint as immune to QR observation, and know that the one-shot claim does nothing against a relayed QR: whoever minted the challenge holds the nonce and is the first poller, so if an attacker mints a challenge on your site and gets a victim to scan it, the attacker's browser receives the session. The wallet's domain check cannot catch this, because the domain really is yours. Mitigations are UX (the wallet shows the domain; your login page tells users to scan only a code shown on that domain) and, if you want more, binding the claim to the creating browser (for example an HttpOnly cookie set when the challenge is created and required on the poll), which DigiScope does not do.

Step 7 — Authenticate later requests

DigiScope's middleware accepts either a session token or an API key on the Authorization: Bearer header and tells them apart by shape:

from backend/src/middleware/auth.js

  const token = authHeader.substring(7); // Remove 'Bearer '

  // Check if token is a session token (64 hex chars)
  if (/^[a-f0-9]{64}$/i.test(token)) {
    // Validate Digi-ID session token
    const user = digiidService.validateSession(token);

Every failure returns the same generic 401 so a prober cannot learn whether a token was well-formed. Logout deletes the session row.

Step 8 — Sweep expired rows

Run a cleanup on a timer. DigiScope stores expires_at as ISO-8601 text and binds the same format when sweeping, because comparing ISO text against SQLite's own datetime('now') text sorts wrongly and silently never deletes same-day rows. That bug existed in production for months before the byte-exact review found it.

from backend/src/services/digiid-service.js

      const nowIso = new Date().toISOString();
      // Delete expired challenges (expires_at is ISO text — bind the same format)
      const deletedChallenges = db.prepare(`
        DELETE FROM digiid_challenges
        WHERE expires_at < ?
      `).run(nowIso);

from backend/src/utils/cleanup-scheduler.js

  // Schedule hourly cleanup (every 60 minutes)
  const intervalMs = 60 * 60 * 1000; // 1 hour
  cleanupInterval = setInterval(runCleanup, intervalMs);

Rate limiting

Give the three login endpoints their own budget so a flood elsewhere cannot starve sign-in, and size it for polling: two-second polls for five minutes is about 150 requests per login attempt.

from backend/src/server.js

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 1000, // generous: Digi-ID status polling + session verifies + retries

Behind a reverse proxy, trust the forwarded address or every client shares one bucket.

The browser side, briefly

The poll loop treats any 4xx as the server's final answer for that challenge and stops, showing the server's message; network errors and 5xx keep polling. That is what turns a 403 "account disabled" or a 409 "already used" into something the user can read instead of an endless spinner.

from src/components/auth/digiIdPollData.js

export function classifyPollError(err) {
  const status = Number(err?.status);

  // No usable status (fetch rejected, DNS, offline, aborted) → transient.
  if (!Number.isFinite(status)) {
    return { terminal: false, kind: 'transient', message: null };
  }

  // 5xx and anything below 400 → server hiccup, worth retrying.
  if (status < 400 || status >= 500) {
    return { terminal: false, kind: 'transient', message: null };
  }

  if (status === 404) {
    return { terminal: true, kind: 'expired', message: null };
  }

On success, store the token and load the user's profile before flipping the app into its signed-in state, so nothing renders against an incomplete user object:

from src/contexts/AuthContext.jsx

  const loginWithDigiID = async (sessionToken, userData) => {
    localStorage.setItem('dgb_session_token', sessionToken);
    localStorage.setItem('dgb_auth_method', 'digiid');
    localStorage.removeItem('dgb_admin_api_key'); // Clear any old API key

Verify it

You do not need a phone to test the server. DigiByte Core can sign a message with the same prefix and header byte the verifier expects.

  1. Decide who may sign in before you debug signatures. DigiScope ships as a closed whitelist: DIGIID_AUTO_PROVISION defaults to false, and an address that is neither linked to an account nor listed in ADMIN_DIGIID_ADDRESS gets 403 { "success": false, "error": "Account not found. Please contact administrator for access." } from the callback even though its signature verified. For this test put DIGIID_AUTO_PROVISION=true in your local .env (listing the address in ADMIN_DIGIID_ADDRESS also works, but grants it the admin role). A 403 has already flipped the challenge row to completed, so after changing the environment restart and mint a fresh challenge.

  2. Start your server locally so it mints challenges against http://localhost:3001. The URI it returns is digiid://localhost:3001/api/auth/digiid/callback?x=<nonce>, with no u=1: the digiid package only appends that flag when constructed with unsecure: true, which createChallenge never does. Sign and post exactly the uri string the response gives you; the verifier compares it byte for byte.

  3. Mint a challenge and copy uri from the response:

    curl -s -X POST http://localhost:3001/api/auth/digiid/challenge
    
  4. Sign it with a legacy address from a Core wallet. signmessage refuses bech32 addresses, which matches the verifier:

    digibyte-cli getnewaddress "" legacy          # → D…
    digibyte-cli signmessage "D…" "digiid://localhost:3001/api/auth/digiid/callback?x=<nonce>"
    
  5. Post the callback exactly as a wallet would:

    curl -s -X POST http://localhost:3001/api/auth/digiid/callback \
      -H 'Content-Type: application/json' \
      -d '{"uri":"digiid://localhost:3001/api/auth/digiid/callback?x=<nonce>","address":"D…","signature":"<base64>"}'
    

    Expected: 200 { "success": true, "sessionToken": "<64 hex>", … }. Post the same body again and expect 401 { "success": false, "error": "Challenge not found or expired" }.

  6. Poll the status endpoint twice, curl -s http://localhost:3001/api/auth/digiid/status/<nonce>: the first call returns 200 with a different session token, the second returns 409 { "status": "claimed", … }.

Check: Mint a fresh challenge first (the one you just used is already claimed, and the pending-status lookup runs before the URI compare, so it would answer Challenge not found or expired instead). Append &foo=1 to the new URI, re-sign that exact string, and post it. You should get 401 Signed URI does not match the issued challenge and the new row should still be pending.

Traps

Where DigiScope's implementation differs from the ideal

Next: the wallet side, in Handle digiid:// in your wallet.