Passkeys in a Next.js app, with challenges kept in Postgres
Usernameless passkey sign-in with SimpleWebAuthn and Server Actions, with single-use challenges in Postgres beside passwords and PINs.

Shazflix is an invite-only streaming app for my family, and most of its users don't want to think about passwords. They watch on phones, laptops and shared screens, and a password is one more thing to forget. With passkeys the device already knows you through face, fingerprint or screen lock, so sign-in is one tap. The work is in the plumbing around the WebAuthn ceremonies, and in fitting passkeys beside the existing sign-in methods.
Where passkeys sit
Passkeys are not the only way in, and they're not the first. The account model looks like this:
- ›Invites. A new member claims an invite code and sets an email and password. Passwords are hashed with Argon2id at the OWASP minimums: 19 MiB of memory, 2 passes, 1 lane.
- ›Sign-in links. I can send a member a one-time link from the admin side. Only the SHA-256 hash of the token is stored, it dies unused after 10 minutes, and consuming it marks the row inside a transaction so it can't be used twice.
- ›Passkeys. Offered after a member has signed in another way, then used for everyday sign-in.
- ›Profile PINs. After any account sign-in, you land on the profile picker. A profile can have a PIN, hashed with the same Argon2id settings. That's a second layer for a shared account, not an alternative to signing in.
So a passkey replaces the password step and nothing else. Every route still lands on /profiles, and a PIN-locked profile still asks for its PIN.
Library and shape
I used @simplewebauthn/server and @simplewebauthn/browser (version 13). Hand-parsing CBOR attestation objects and COSE keys is not where I wanted to spend my time, and the library's JSON types line up cleanly with what crosses the network.
The ceremonies run through Server Actions, not route handlers. Each ceremony is two actions: one returns options, the other verifies the browser's response. The client half is short: fetch options, hand them to the browser's prompt, send the result to the verify action, and follow where the server says to go next.
The interesting part is failure. The browser throws one NotAllowedError for a cancelled prompt, a timeout, and a device with no passkey for the site, and can't tell you which. A red error for "I tapped cancel" felt hostile, so on that error the button goes quiet and the password form stays as the fallback. Any other failure suggests the password instead.
The verify actions don't trust the payload's shape either. Before anything reaches SimpleWebAuthn, a Zod schema checks the response: base64url strings, the type is exactly public-key, and transports come from a fixed list capped at ten entries. Anything else gets a polite failure.
Challenges in Postgres
The part that needs real decisions is the challenge. WebAuthn's replay protection depends on the server issuing a random challenge, the authenticator signing it, and the server accepting each challenge exactly once. Most examples keep it in the session. I keep it in a passkey_challenges table: the challenge itself, a purpose that is either register or auth, an optional user id, an expiry and a creation time. A unique index covers the challenge and purpose together.
The reason is the sign-in ceremony. It's usernameless: the person hasn't typed an email, so there's no account and no session to hang the challenge on. Instead, the server finds the challenge from the response itself. The browser echoes the challenge inside clientDataJSON, so I decode that, then consume the matching row.
Consuming is a single delete matching the challenge, the purpose, an unexpired timestamp (challenges live 5 minutes) and the user id when one was given. If a row comes back, the challenge was valid and is now gone. If not, it was never issued, already used or expired, and all three are treated the same.
Key ideaA challenge is consumed by the same DELETE that finds it, so a replayed or raced response has nothing left to match.
A few details make this hold together:
- ›
DELETE ... RETURNINGis the read and the consume in one statement. Two concurrent verifications of the same response can't both get a row back. - ›The challenge then goes to SimpleWebAuthn as the expected challenge, and the library checks the signed
clientDataJSONagainst it. Pulling the value out of the client's data looks circular, but it isn't: I only accept it if it matches a row I issued and haven't used, and the signature check proves the authenticator signed that exact value. - ›Registration challenges are bound to the signed-in user's id, so a registration response can only complete for the account that started it. Authentication challenges have no user, by design.
- ›Challenges are stored in plain text. Knowing one is useless without the private key that has to sign it.
- ›There's no cron job for clean-up. Each new challenge deletes rows that expired over an hour ago before inserting, which is cheap at this scale.
Registration
On /profiles, if the account has no passkeys yet and the browser reports a built-in authenticator, a banner offers to save one. "Not now" is remembered in localStorage, so it doesn't nag. The same button lives under the profile screen for adding a second device.
The options are generated on the server, and each one is a small decision:
- ›RP ID and origin come from one validated env value, the app's public URL. The RP ID is its hostname and the only accepted origin is its origin. Passkeys are bound to that hostname, so a credential saved on one deployment hostname won't answer on another. Having one source for both values means they can't drift apart.
- ›The user handle is the account's UUID as 16 raw bytes. It's stable and opaque, and it doesn't contain the email. The email and name are still passed as the display values the prompt shows.
- ›Attestation is
none. I don't need to know which manufacturer made the authenticator, so there's nothing to gain from asking. - ›The exclude list carries the member's existing passkeys, so the browser refuses to register the same authenticator twice.
- ›User verification is required on both ceremonies. A passkey has to be confirmed with a face, fingerprint or device PIN, not only touched.
- ›A member can hold at most ten passkeys.
On verification, the credential ID, the public key as base64url, the signature counter, transports, device type and the backed-up flag go into passkey_credentials. The backed-up flag tells me whether it's a synced passkey or one tied to a single device, and the member's profile screen labels each one "Synced passkey" or "Tied to one device".
Authentication
Sign-in options are deliberately bare: the RP ID and required user verification, with no allow-list of credentials. An empty allow-list tells the browser "any discoverable credential for this site will do", which is what makes the flow usernameless. The response's credential ID is then the lookup key into passkey_credentials, and that row's public key and counter go to the library's verification step.
On success, the counter, last_used_at, device type and backed-up flag are updated, a server-side session is created exactly as it is for a password sign-in, and an audit row is written. Failures are audited too. Both sign-in actions are rate limited at 30 attempts per IP per 15 minutes, even though passkeys can't be guessed. Without the limit, the options action would let anyone fill the challenges table.
A gotcha I hit
The visible bug was a hydration mismatch. The passkey button originally checked for WebAuthn support during render and rendered nothing without it. The server has no window, so the check says no; the browser says yes. React saw two different trees.
The fix was useSyncExternalStore, which takes a separate server snapshot. The server snapshot always says "not supported", the client one asks the browser, and the subscription does nothing. The button joins after hydration without a mismatch. The save banner checks inside an effect for the same reason, since the platform-authenticator check is async and browser-only anyway.
What I'd do differently
- ›Keep every limit in one named constant and test that it's enforced. Caps and attempt limits tend to get written in two modules, and two copies of a number drift apart quietly. A test that pushes one past the limit is cheaper than rereading the code to be sure.
- ›Use conditional mediation, so passkeys appear in the browser's autofill on the email field. Right now sign-in is a separate button, which some people will never press.
- ›Bind authentication challenges to a short-lived cookie as well as the table. Matching by echoed value is safe, but a cookie would let me throw out responses from a different browser tab before touching the database.
- ›Show a member which device each passkey belongs to. I store transports and device type, but "synced passkey added in October" isn't much help when you're deciding which one to remove.