I'm building ClientN, so read this as a founder's walkthrough, not a neutral review.
Most small sites keep a users table with an email column only because the login needs one. The email then sits there for years, gets copied into backups, and turns up in breach notices. Passkeys remove the password. The question here is whether you can also stop receiving the email.
This post walks through the flow in our open-source reference integration, clientn-session-starter (Node.js + PHP, MIT, with tests). It is not an SDK. You write server code, and the repo shows one complete, tested way to do it.
What your site gets instead of an email
After the visitor confirms with a passkey on clientn.com, your server receives a clientn_id (CN-…). That id stays the same for this person on your site and is different on every other site, so two sites can't match their users by it. You never receive a password or an email address.
The five server steps
-
Start a session. Your server calls
POST /api/v1/sessionswith your API key. ClientN returns a session id, a confirm URL, a QR code and a 4-character match code. -
Bind it to this browser. Store a pending login tied to the browser: a random token in an HttpOnly cookie, and only its hash in your database. The page shows the QR code and the match code (
clientn.jshandles the display). - The person confirms. They open the confirm URL (a link on the same device, or the QR code on a phone), check that the match code and site name are right, and confirm with their passkey.
- Verify the signed callback. ClientN POSTs a signed callback to your server. Verify the signature over the exact raw bytes. Check that the callback belongs to a pending login you started. Mark it confirmed in one atomic step.
-
Log the right browser in. The browser holding the pending cookie polls your
/clientn/status. Once the login is confirmed, consume it exactly once, create a fresh session id and set your app cookie.
If the callback is lost, the status route can ask ClientN directly (GET /api/v1/sessions/{id}) and verify that signed answer the same way. You can also call POST /api/v1/sessions/{id}/redeliver within 30 minutes.
Why the flow is shaped like this
A few choices look fussy at first, but each one blocks a real attack:
- Poll your own server, not ClientN. ClientN's public status only says the person tapped Confirm. You are logged in only after your server has accepted the signed callback.
- Store the browser binding as a hash. Another browser that learns the session id still can't take over the login.
- Consume the login once. In the test suite, five parallel polls create exactly one session.
- Treat a repeated event as a no-op. Callbacks are retried 3 times, 5 seconds apart. Return 2xx as soon as you've recorded the event, and make the same event arriving again do nothing.
Both apps are tested against the same list: cross-site start is refused, the API key never reaches the browser, tampered or oversized callbacks are rejected, expired logins are refused, and a forged recovery answer is ignored.
Running it
# Node 22.5+ (built-in node:sqlite, no dependencies)
cd node && cp .env.example .env # add your key + secret
npm start && npm test
# PHP 8.1+ with pdo_sqlite and curl
cd php && php tests/run.php
First register your site at clientn.com/dev and verify your domain with a DNS TXT record or a /.well-known/ file. Callbacks are sent over HTTPS on port 443 to your verified domain and don't follow redirects, so test on a real staging subdomain, not localhost.
Honest limits
- Keys have no scopes yet: one key and secret per site. Rotating them ends any sign-ins in progress.
- The demo apps use an in-memory rate limiter. Use your real shared limiter in production.
- It only helps on sites that integrate it. It won't make the rest of the web private.
- Website plans are free up to 1,000 logins per month until 2027-03-31.
Try the flow yourself on the live demo, or read the docs. If you run a small site, I'd like to hear which step would block you. That's the feedback I'm collecting this month.
Top comments (0)