Engineering
Passkeys without telling anyone who has an account
Business logins can now sign in with a passkey. The WebAuthn library does the cryptography, and most of our work went into everything around it: where the relying party comes from, challenges that live on the server and are used once, a sign-in prompt that never asks who you are, sign counters, and a fresh check before a session can add a passkey of its own.
The short version
- Passkey sign-in for business logins uses SimpleWebAuthn (server 14.0.3, browser 14.0.0). The library verifies the signatures. We own the relying party, the challenges, the counters and every rule about who may add or remove a passkey.
- The relying party comes from the configured public URL, never from the request's Host header. Its origin, its rpID and user verification are checked by our own code after the library has checked them.
- Every challenge is stored on the server for five minutes and deleted in the same statement that checks it. A sign-in challenge is bound to the hash of a random value in the asking browser's httpOnly cookie.
- The sign-in prompt asks for any passkey for this site, with an empty allow list, so it never takes an email address. A refused answer reads the same whether the passkey is known or not.
- A sign counter that stands still or goes backwards is refused, and the update that records a new counter only lands on the counter it was checked against.
- Adding a passkey needs a fresh password or two-step code (or, with neither, a sign-in in the last ten minutes), and the last passkey cannot be removed when it is a login's only way in.
A passkey is a key pair an authenticator makes for one site: Face ID or Touch ID, Windows Hello, an Android phone, a password manager or a security key. The private half never leaves the device. We keep the public half and check each sign-in's signature against it. Because we require user verification every time, a passkey sign-in is something you have and something you are or know in one step, and a business that requires two-step login accepts it.
We used SimpleWebAuthn rather than writing CBOR and COSE parsing ourselves. That still left plenty for us to decide, and most of what can go wrong with passkeys lives in those decisions rather than in the cryptography.
The relying party is configuration
WebAuthn ties every credential to a relying party id, a host name, and checks each answer against an expected origin. The easy way to get both is to read them off the incoming request. That would let whoever controls the Host header choose them, so we never do. lib/passkeys.ts reads the configured public URL:
export function relyingParty(): RelyingParty | null {
return relyingPartyFor(process.env.NEXT_PUBLIC_APP_URL, process.env.NODE_ENV);
}relyingPartyFor is pure and refuses anything a browser would never make a passkey on. An IP address is never a valid relying party id, and WebAuthn needs https, or plain http on localhost:
const host = url.hostname.toLowerCase();
if (!host || /^[\d.]+$/.test(host) || host.includes(":") || host.startsWith("[")) return null;
const localhost = host === "localhost" || host.endsWith(".localhost");
if (url.protocol !== "https:" && !(url.protocol === "http:" && localhost)) return null;
return { rpID: host, origin: url.origin.toLowerCase() };null means passkeys are off on that address, and every door says so plainly. The integration test sends requests that claim to come from another host and checks that the options still name the configured one.
Checked twice, on purpose
We pass expectedOrigin, expectedRPID and requireUserVerification: true to the library. Then, after it says yes, we check the same three things again on what it returns:
const info = verified?.verified ? verified.registrationInfo : null;
if (
!info ||
info.userVerified !== true ||
info.origin !== rp.origin ||
(info.rpID !== undefined && info.rpID !== rp.rpID) ||
!info.credential?.id ||
!(info.credential.publicKey instanceof Uint8Array)
) {
return { ok: false, error: NOT_ADDED };
}This is not distrust of the library. It means a default changing in a future major version, or a call site that forgets one option, cannot quietly loosen the rule. The sign-in path does the same and also checks that the credential id the library verified is the one we looked up. The tests replace the library with a mock that says "verified" while reporting a wrong origin, a wrong rpID or no user verification, and expect a refusal for each.
One-time challenges on the server
A WebAuthn answer signs a random challenge the server issued. If the server does not remember which challenges it issued, or lets one be used twice, a captured answer can be replayed. Migration 0171 adds a webauthn_challenges table with a constraint that every row is bound to something:
constraint webauthn_challenges_bound check (
(purpose = 'register' and user_id is not null) or (purpose = 'sign_in' and attempt_hash is not null)
)A challenge for adding a passkey names the signed-in login. A sign-in challenge cannot name a login, because nobody is known yet, so it names the browser. Using a challenge is a single DELETE ... RETURNING, so checking it and spending it are one statement:
const rows = await db
.delete(webauthnChallenges)
.where(
and(
eq(webauthnChallenges.challenge, challenge),
eq(webauthnChallenges.purpose, purpose),
"userId" in bound ? eq(webauthnChallenges.userId, bound.userId) : eq(webauthnChallenges.attemptHash, bound.attemptHash),
gt(webauthnChallenges.expiresAt, new Date(nowMs())),
),
)
.returning({ id: webauthnChallenges.id });
return rows.length === 1;Two answers racing with the same challenge cannot both get a row back. The purpose is part of the match, so a challenge issued for adding a passkey can never finish a sign-in. Expired rows are cleared each time a new challenge is stored, so the table only ever holds the last few minutes.
Binding a sign-in to the browser that asked
For a sign-in, the browser that asks for a challenge gets an httpOnly cookie holding 32 random bytes. The server never stores that value, only a hash of it:
export function attemptHashOf(token: string): string {
return crypto.createHash("sha256").update(`bookatu:passkey-attempt:v1:${token}`).digest("hex");
}The cookie lives as long as a challenge does, five minutes. A challenge copied out of one browser and answered from another finds no matching row, because the other browser's cookie hashes to something else, or it has no cookie at all. The test covers both, plus the opposite case that matters for usability: autofill and the button can each open a prompt in the same browser, and both still work.
The challenge endpoint is a route handler, POST /api/passkey/options, and not a server action. Setting a cookie inside a server action makes Next.js re-render the page. The sign-in page asks for a challenge as soon as it opens, so as an action it reloaded the page under every visitor while they typed. A test now pins that the challenge comes from the route. The same lesson came up again in auto sign-out, in the same release.
A prompt that never asks who you are
A sign-in form that takes an email address and then says "this address has no passkey" tells anyone who asks which addresses have accounts. So the passkey prompt takes no address at all:
const options = await generateAuthenticationOptions({
rpID: rp.rpID,
userVerification: "required",
timeout: PASSKEY_PROMPT_TIMEOUT_MS,
});
await storeChallenge(options.challenge, "sign_in", { attemptHash });With no allowCredentials list, the browser offers whichever passkeys it holds for this site. That needs discoverable credentials, so registration asks for residentKey: "preferred", and it sets the user handle to our own user id, never the email. When an answer carries a user handle, it has to name the login that holds the passkey, or the sign-in is refused.
The other half of not telling is the refusal. An unknown credential and a bad signature return the same reason to the browser. The difference only shows in our own audit trail: a failure on a known passkey is recorded on that login, like a wrong password, and an unknown one leaves nothing anywhere. Tries are limited per IP, with the same counter the password form uses, and per passkey, under a hash of its credential id.
Counters against copied authenticators
An authenticator can report a sign counter that goes up at each use. If it stands still or goes backwards, the key may have been cloned. Most synced passkeys always report zero, so zero and zero has to be allowed:
export function counterAccepted(stored: number, next: number): boolean {
if (!Number.isInteger(next) || next < 0 || !Number.isInteger(stored) || stored < 0) return false;
if (stored === 0 && next === 0) return true;
return next > stored;
}Checking is not enough on its own if two sign-ins with the same counter arrive together. The update that records the new counter carries the old one in its WHERE clause, so only one of them lands:
const updated = await db
.update(userPasskeys)
.set({ counter: info.newCounter, lastUsedAt: new Date(nowMs()), backedUp: Boolean(info.credentialBackedUp) })
.where(and(eq(userPasskeys.id, passkey.id), eq(userPasskeys.counter, passkey.counter)))
.returning({ id: userPasskeys.id });
if (!updated.length) return { ok: false, reason: "failed", userId: user.id, why: "counter" };A counter refusal is written to the login's audit trail with its own wording, which says the passkey may have been copied, so the owner of the login can see it.
A stolen session cannot add a passkey
If someone gets hold of a signed-in session, a passkey is the most useful thing they could add: it outlives the session and works without the password. So adding one asks for a fresh proof first. That is the password, or a two-step code (spent like any code), when the login has either. A login with neither, one that signs in with Google only, needs a sign-in from the last ten minutes:
export function passkeyProofKind(login: { hasPassword: boolean; twoStepOn: boolean }): PasskeyProofKind {
if (login.hasPassword && login.twoStepOn) return "password_or_code";
if (login.hasPassword) return "password";
if (login.twoStepOn) return "code";
return "recent_sign_in";
}The proof is checked before any challenge is made, and it is limited to five tries in fifteen minutes per login, so a session cannot guess its way to the password through this form either. Adding a passkey also needs Manage at the business. Renaming and removing your own passkeys stays open to every member, because someone who later loses Manage must still be able to remove the passkey of a device they lost.
Never the last way in
Removing a passkey must never leave a login with no way to sign in. The rule is small:
export function mayRemovePasskey(login: { hasPassword: boolean; hasGoogle: boolean; othersLeft: number }): boolean {
return login.hasPassword || login.hasGoogle || login.othersLeft > 0;
}The race is the interesting part. For a login with no password and no Google sign-in, two tabs removing its last two passkeys at the same moment would each see one other passkey left, and both would go. removePasskey runs in a transaction that first locks the login's row with FOR UPDATE, then counts, then deletes, so the second removal counts after the first has committed.
Where a passkey shows up later
A passkey sign-in records which passkey it used in the session. When a business requires two-step login, the guard accepts that session only while the passkey is still on the login, so removing a passkey also stops it counting as a second step. The phone app is unchanged. Its token never names a passkey, and the tests check that it is refused at a two-step business just as before.
What we take from it
- Take the relying party from configuration. Anything read from the request is chosen by whoever sent the request.
- Check the security properties you depend on in your own code, even when you also pass them to the library. It costs a few lines and protects you from a changed default.
- Make spending a challenge and checking it the same statement. DELETE ... RETURNING gives you single use for free.
- If the prompt never asks who you are, it cannot tell anyone who has an account. Make every refusal read the same.
- Ask for proof before adding a long-lived credential, and lock the row before removing the last one.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs