Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

Add sign-in with fingerprint or face (passkeys) and per-site anonymous IDs to a Node or PHP app

I built ClientN. This is a how-to plus a small challenge. Most "Sign in with X" buttons give the site a stable ID that other sites can match. ClientN gives each site a different anonymous ID for the same person. The per

I built ClientN. This is a how-to plus a small challenge.

Most "Sign in with X" buttons give the site a stable ID that other sites can match. ClientN gives each site a different anonymous ID for the same person. The person signs in with fingerprint or face (a passkey), there is no password, and your site does not receive an email address.

Honest limit first: ClientN does not check IDs or faces, so it cannot prove that two accounts are different people. ID and selfie checks are paused until licensed, and nothing is collected.

Here is how to add it to a Node or PHP app. The full, tested code is in the MIT starter repo: github.com/clientn/clientn-session-starter.

What you need

  • A free ClientN account and a site registered on clientn.com/dev.
  • A verified domain: a DNS TXT record or a file under /.well-known/ (the page shows the exact values). Sessions are refused until then.
  • A public HTTPS host on port 443 (a staging subdomain is fine).

Step 1: start a sign-in on your server

The button POSTs to your own endpoint. Your server makes a random code_verifier, keeps it server-side, bound to this browser, and asks ClientN for a session with the hash of it:

const verifier = randomBytes(32).toString('base64url');   // never leaves your server
const s = await createClientNSession(cfg, {
  flow: 'redirect',
  return_url: cfg.appOrigin + '/clientn/return',
  code_challenge: createHash('sha256').update(verifier).digest('base64url'),
  end_user_ip: req.socket.remoteAddress,
  end_user_agent: String(req.headers['user-agent'] || '').slice(0, 300),
});
// store { session_id, verifier } in a pending row tied to a cookie, then redirect to s.confirm_url

The API key stays on the server. The browser never sees it.

Step 2: the person confirms on clientn.com

They confirm with fingerprint, face or device PIN. Every approval needs a fresh passkey check, tied to this one request. ClientN then sends the browser back to /clientn/return?cn_session=…&cn_code=….

Step 3: exchange the one-time code

Only the browser holding your pending cookie has the verifier, so a forwarded confirm link signs nobody in:

const row = store.claimRedirect(cookieToken, sid);        // claim this browser's pending row ONCE
const r = await exchangeClientNSession(cfg, sid, code, row.verifier);

Step 4: verify the signature over the exact bytes

const expected = createHmac('sha256', secret).update(rawBody).digest();
if (!timingSafeEqual(expected, Buffer.from(signature, 'hex'))) throw new Error('bad signature');

Check that session_id matches the pending login you started and that it has not expired.

Step 5: start a fresh app session

const appSid = store.startAppSession(ev.clientnId);       // new session id, old pending cookie cleared

clientn_id is stable for one person on your site and different on every other site. That is the whole point.

There is also a QR flow (a phone confirms for a computer) with number matching and a signed server-to-server callback. It is in the same repo, with tests for replay, CSRF, tampered callbacks and "five parallel polls create one session".

PHP works the same way: see php/ in the repo. For WordPress, a plugin is in review on wordpress.org.

Try it

The challenge

Building something with it? Submit your site on clientn.com/challenge before 15 Dec. The 3 best builds get $40 each (ranking, not a draw). There is also an invite track. 18+, free account needed. Rules: clientn.com/challenge

Criticism is very welcome, especially where you think the privacy claim breaks.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.