Deep Dive: Passkey Authentication for a Retro BBS
Passkeys in a 300‑baud world: the smallest WebAuthn flow, the server state it requires, and how I kept it in tune with emulator.ca's identity model.
On this page
I wanted users to sign into a fake 1980s BBS with Face ID.
What that actually requires is a server-side challenge, a device signature, and a credential store that survives restarts. The cryptography is handled by webauthn-rs. The work is in state continuity between the browser and the server, which is what this article is about.
The Constraint That Shapes Everything
WebAuthn rests on one invariant: challenges are single-use, time-boxed, and issued by the server. A browser that generated and signed its own challenge would be proving nothing. The server issues the challenge, keeps it, and verifies that the returned signature matches.
So the ceremony needs somewhere to keep state between the begin and finish calls. You could put that somewhere other than a database — signed cookies, an in-memory map — but both of those constrain deployment in ways I didn’t want, so on emulator.ca it’s a table.
Challenges live in passkey_challenges, keyed by the base64url-encoded challenge string. Each record expires after five minutes and is deleted on use, which is what keeps replayed responses from verifying.
Two Ceremonies, One Pattern
WebAuthn has two ceremonies: registration creates a credential, authentication proves you hold one. Both follow the same rhythm.
Registration:
POST /v1/auth/passkey/register/beginwith a handle or existing JWT- Server builds creation options, serializes the
PasskeyRegistrationstate, stores it inpasskey_challenges.state_data - Browser runs
navigator.credentials.create()via SimpleWebAuthn POST /v1/auth/passkey/register/finishextracts the challenge from the response, looks up the state, verifies, stores the credential
Authentication:
POST /v1/auth/passkey/login/beginwith a handle hint- Server builds assertion options, serializes
PasskeyAuthenticationstate intostate_data - Browser runs
navigator.credentials.get() POST /v1/auth/passkey/login/finishverifies the signature and mints a JWT
Issue challenge → store state → receive response → verify → delete challenge. The rest is bookkeeping.
Why State Lives in the Database
webauthn-rs hands you an opaque struct between the two halves of a ceremony — PasskeyRegistration or PasskeyAuthentication — that you must serialize and return intact. Lose it and verification fails.
I serialize it as JSON into passkey_challenges.state_data:
CREATE TABLE passkey_challenges (
challenge TEXT UNIQUE NOT NULL,
account_id UUID,
challenge_type VARCHAR(32) NOT NULL, -- 'registration' or 'authentication'
user_handle TEXT,
state_data TEXT, -- serialized webauthn-rs state
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '5 minutes'
);
The challenge is the lookup key. When the client sends its response, I pull the challenge out of clientDataJSON, look up the stored state, deserialize it, and hand both to webauthn-rs.
Because the state is in Postgres rather than in memory, this survives server restarts and works across multiple instances.
The Client: Thin and Explicit
The browser side is a wrapper over @simplewebauthn/browser. It adds client-side timeouts (two minutes by default) and surfaces the failure modes that actually happen:
try {
registrationResponse = await this.withTimeout(
startRegistration({ optionsJSON: beginData.options }),
this.ceremonyTimeoutMs,
'Passkey registration'
);
} catch (error) {
if (error.name === 'NotAllowedError') {
return { success: false, error: 'Registration was cancelled' };
}
if (error.name === 'InvalidStateError') {
return { success: false, error: 'This authenticator is already registered' };
}
// ...
}
NotAllowedError means the user cancelled. InvalidStateError means they tried to register a credential that’s already registered for this account. TimeoutError is my own wrapper; SimpleWebAuthn doesn’t time out on its own.
There’s no session cookie involved. The ceremony state lives on the server and the client shuttles data back and forth.
Credential Storage: What WebAuthn Cares About
The credential table is shaped around WebAuthn’s invariants:
CREATE TABLE passkey_credentials (
credential_id TEXT UNIQUE NOT NULL, -- base64url-encoded
public_key BYTEA NOT NULL, -- serialized Passkey struct
counter BIGINT NOT NULL DEFAULT 0,
name VARCHAR(64) DEFAULT 'Passkey',
-- ...
);
The public_key column doesn’t store just the public key—it stores the entire serialized Passkey struct from webauthn-rs. This includes the credential ID, the COSE key, the counter, and any attestation data. On each successful authentication, I call passkey.update_credential(&auth_result) and persist the updated struct:
if auth_result.needs_update() {
passkey.update_credential(&auth_result);
passkeys_db.update_credential_with_passkey(&credential_id, &updated_json).await;
}
The counter is WebAuthn’s clone detection mechanism. If it ever moves backwards, something has been cloned, and the credential should be revoked.
Relying Party Configuration
Passkeys only work when the RP ID and origin match. The server builds PasskeyState from environment config:
PASSKEY_RP_IDisemulator.cain production,localhostin developmentPASSKEY_RP_NAMEisEmulator.ca- The origin comes from the server’s base URL
If the RP ID doesn’t match the domain, the browser refuses to sign. If the origin doesn’t match, verification fails. Both failures are quiet, which makes a misconfigured RP ID one of the more annoying things to debug here.
UI: Show It Only When It’s Real
The auth component queries both browser support and server capability before showing the passkey button:
const [oauthStatus, passkeysSupported] = await Promise.all([
oauthClient.getStatus(),
Promise.resolve(passkeyClient.isSupported()),
]);
this.passkeysSupported = passkeysSupported && oauthStatus.passkeys_enabled;
If the server can’t complete a ceremony (no database, misconfigured RP), it sets passkeys_enabled: false in the status response. The browser might support WebAuthn, but if the server can’t play along, the button doesn’t appear.
Handle Validation: Security, Not Just UX
Handles are validated early and strictly. The server rejects anything that starts with GUEST_—that prefix is reserved for anonymous sessions:
if handle.starts_with("GUEST_") {
return Err((StatusCode::BAD_REQUEST, Json(RegisterBeginResponse {
success: false,
error: Some("Handle cannot start with GUEST_".to_string()),
})));
}
This isn’t only about preventing confusion. Guest sessions have different capabilities and data retention rules, so registering a passkey for GUEST_FOO would create an account with permanent credentials and guest-level trust.
The result sits next to OAuth without special-casing anything. A user can sign in with Face ID, link their GitHub account, and switch between them; both paths produce the same JWT, because the identity model only cares that something proved you are you.
The cryptography was never where the time went. I never had to think about ECDSA or attestation formats. The work was state management, error surfacing, and keeping the UI honest about what the server can currently do.
You can now sign into a fake 1980s BBS with Face ID, which I still find funny.