Passkeys System Design
A credential that cannot be phished, cannot be reused across sites, and leaves nothing worth stealing in the database.
internal/handlersinternal/modelsgo-webauthn/webauthn1.Problem statement
Every shared-secret factor has the same flaw. The user holds something the server also holds, or can recompute, and anything the user can be persuaded to type can be persuaded out of them by a page that looks right. Two-factor codes narrow the window; they do not close it, because a proxy that relays the code in real time still works.
Public key authentication closes it. The authenticator keeps a private key that never leaves it, the server keeps only the public half, and the browser will only sign a challenge for the origin the credential was registered to. A fake page at a lookalike domain cannot get a usable signature, and the user has no secret to hand over even if they want to.
What makes this a system rather than a library call is everything around the key: a challenge that must be used once, credentials that belong to a user who may have several, a counter that detects a cloned authenticator, and a path for the user whose only passkey is on a device they no longer have.
The system has to be able to:
- Register an authenticator against a relying party derived from the application origin.
- Issue a challenge that is random, short-lived and single-use.
- Verify a signature against a stored public key, and reject one produced for a different origin.
- Hold several credentials per user, because people have a laptop and a phone.
- Notice a signature counter that goes backwards, which means the authenticator was cloned.
- Let a user name, list and remove their credentials, and keep a second factor available if they remove the last one.
2.System requirements
Functional requirements
- Begin and finish registration, storing the credential id, public key, sign count and transports.
- Begin and finish authentication, with the user either named or discovered from the credential.
- Derive the relying party id from the configured origin, and refuse to start if that origin is not set correctly.
- Store challenges server-side with a short expiry and delete them on use.
- List a user their passkeys with a name, the device it was created on and the last use.
- Remove a passkey, and warn when it is the last one.
Non-functional requirements
- Nothing worth stealing. the stored half is public. A full database dump yields no credential that can be used to sign in.
- Origin bound. the browser refuses to sign for an origin the credential was not registered to, which is what makes the credential unphishable rather than merely strong.
- Single-use challenges. a challenge is generated server-side, stored, and deleted when consumed. A replayed assertion finds nothing to match.
- Clone detection. a sign counter that does not advance is evidence the authenticator was copied, and is surfaced rather than ignored.
- Never the only door. a user who removes their last passkey still has a password and, if enrolled, a second factor. Authentication does not become unrecoverable.
3.Capacity estimation
Numbers for a mid-sized deployment. They are here to size the thing, not to predict your traffic: change an assumption and the sums below move with it.
Assumptions
| Parameter | Value |
|---|---|
| Users with a passkey | 10% of 1,000,000 = 100,000 |
| Passkeys per user | 1.6 |
| Credential row | ~400 bytes, mostly the public key and transports |
| Challenge lifetime | 2 minutes |
| Sign-ins per passkey user per day | 1.2 |
Credential storage
Trivial. The storage question for passkeys is never size, it is making sure the credential id is indexed, because discoverable sign-in looks up by it with no user named.
Challenge churn
Two hundred rows. This is the clearest case in the whole framework for a short-lived store rather than a durable table.
Verification cost
Verification is roughly 100 microseconds. Compared with bcrypt at 60 milliseconds, passkey sign-in is cheaper for the server than password sign-in by two orders of magnitude.
4.High level design
Both ceremonies are two requests: the server issues a challenge, the authenticator signs it, the server verifies. The interesting state is the challenge, which must exist between the two.
Core components
- WebAuthn service. wraps the protocol library. Builds the creation and request options, and verifies what comes back against the stored credential.
- Relying party config. the id and origin, derived from the configured application origin. Getting this wrong is the single most common setup failure, so it is validated at startup.
- Challenge store. short-lived server-side state tying a challenge to the user or the session that asked for it.
- Credential store. the public halves, one row per authenticator, indexed by credential id for discoverable sign-in.
- Counter check. compares the sign count in the assertion against the stored one and flags a non-advance.
Request flow
Signing in with a passkey
- 1The browser asks to begin. It may name a user, or ask for a discoverable credential and let the authenticator decide.
- 2The handler asks the WebAuthn service for request options: the relying party id, the allowed credentials if a user was named, and a fresh random challenge.
- 3The challenge is stored server-side with a short expiry. It is the only thing that makes the second request verifiable, and it is good once.
- 4The browser passes the options to the authenticator, which asks the user for a fingerprint, a face or a PIN, and signs the challenge with the private key. That key does not leave the device.
- 5The assertion comes back: the credential id, the signature, the authenticator data and the client data.
- 6The handler looks up the stored public key by credential id and asks the service to verify. The verification fails if the origin in the client data is not this application, which is what defeats a lookalike page.
- 7The sign count in the assertion is compared with the stored one. A count that did not advance suggests a cloned authenticator and is recorded as a security event.
- 8On success the challenge is deleted, the stored count and last-used stamp are updated, and the ordinary token pair and session are issued. From here it is the same sign-in as any other.
Data flow
- The private key never reaches the server, and there is no API that could ask for it.
- The challenge exists between the two requests and is deleted on use, so an intercepted assertion cannot be replayed.
- The relying party id is derived from the configured origin. It is checked at startup rather than at first use, so a misconfiguration fails on deploy instead of on a user.
- The credential id is the lookup key for discoverable sign-in, where no username is sent at all.
5.Technology stack
| Component | What it is |
|---|---|
| Protocol | WebAuthn level 2, via go-webauthn |
| Credential storage | the primary database, public key only |
| Challenge storage | short-lived server-side state with an expiry |
| Relying party | derived from the configured application origin |
| Client API | navigator.credentials, with a conditional-UI autofill path |
6.Data model
passkeys
| Column | Holds |
|---|---|
| id | UUIDv7 |
| user_id | owner |
| credential_id | the authenticator’s id, unique and indexed for discoverable sign-in |
| public_key | the public half, as returned at registration |
| sign_count | the last counter seen; a non-advance is suspicious |
| transports | usb, nfc, ble, internal, hybrid; used to prompt sensibly |
| name | what the user calls it |
| created_at, last_used_at | for the management list |
webauthn_challenges
Deliberately short-lived. Roughly two hundred rows exist at any moment on the traffic above.
| Column | Holds |
|---|---|
| challenge | random bytes, the thing to be signed |
| user_id | null for a discoverable sign-in |
| kind | registration or authentication |
| expires_at | minutes, not hours |
7.API design
Registration
| Method | Endpoint | What it does |
|---|---|---|
| POST | /api/v1/auth/passkeys/register/begin | Creation options and a challenge |
| POST | /api/v1/auth/passkeys/register/finish | Verify and store the credential |
Sign-in
| Method | Endpoint | What it does |
|---|---|---|
| POST | /api/v1/auth/passkeys/login/begin | Request options and a challenge |
| POST | /api/v1/auth/passkeys/login/finish | Verify the assertion, issue tokens |
Management
| Method | Endpoint | What it does |
|---|---|---|
| GET | /api/v1/auth/passkeys | The user’s credentials |
| PATCH | /api/v1/auth/passkeys/:id | Rename one |
| DELETE | /api/v1/auth/passkeys/:id | Remove one |
8.Low level design
Core types
The protocol, wrapped once. Builds options and verifies responses, and is the only place the library’s types appear.
BeginRegistrationFinishRegistrationBeginLoginFinishLoginFour endpoints, each thin. The interesting rule it enforces is that a finish request must match a challenge this server issued.
The stored credential. Implements the library’s credential interface so the service does not need a translation layer.
Design principles applied
- Do not reimplement the standard. the protocol is subtle and the failure mode is silent. A reviewed library handles it; the application handles the storage and the policy.
- Validate configuration at startup. a wrong relying party id produces a browser error with no server-side trace. Checking the origin on boot turns an invisible failure into a loud one.
- Keep the fallback. passkeys are an addition, not a replacement. Removing the last one does not remove the ability to sign in.
Patterns
| Pattern | Where it is used |
|---|---|
| Challenge and response | server-issued nonce, signed by the holder of the key |
| Two-phase ceremony | begin stores state, finish consumes it |
| Adapter | the stored model implements the library’s credential interface |
9.Scalability and performance
- Signature verification is around a hundred microseconds, which makes passkey sign-in cheaper for the server than a bcrypt comparison by a wide margin.
- The credential table is read by a unique index on credential id. It does not grow with traffic, only with users.
- Challenges are the only churn, and the live set is tiny. They are a natural fit for a store with expiry rather than a table that needs sweeping.
- Nothing here is shared between replicas except the challenge, so a load balancer does not need sticky sessions provided the challenge store is shared.
- The relying party id is tied to the domain. Serving the same application on a second domain means a second set of credentials, which is a product decision, not a scaling one.
10.Bottlenecks and improvements
What breaks first
- Origin misconfiguration. the most common failure by a wide margin. A wrong origin produces a browser-side error and nothing in the server log.
- Challenge store as a single point. if challenges live only in one replica’s memory, a load balancer that sends the finish request elsewhere breaks every sign-in.
- The last-passkey problem. a user who removes their only credential from their only device, with no password set, has no way back in.
- Counter false positives. some authenticators do not implement the counter and always report zero, so a naive non-advance check flags every one of them.
What to do about it
- Check the origin on boot. refusing to start, or warning loudly, when the configured origin cannot produce a valid relying party id moves the failure from a user to a deploy.
- Share the challenge store. putting challenges where every replica can see them removes the sticky-session requirement, which is otherwise an invisible constraint.
- Warn on the last credential. the management page says what will happen before the last passkey is removed, and points at the password and second-factor settings.
- Treat a zero counter as absent. an authenticator that always reports zero is not a clone. The check applies only where the counter has ever advanced.
