Skip to main content
Back to articles
2026 / 01
| 5 min read

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.

emulator authentication passkeys webauthn security
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:

  1. POST /v1/auth/passkey/register/begin with a handle or existing JWT
  2. Server builds creation options, serializes the PasskeyRegistration state, stores it in passkey_challenges.state_data
  3. Browser runs navigator.credentials.create() via SimpleWebAuthn
  4. POST /v1/auth/passkey/register/finish extracts the challenge from the response, looks up the state, verifies, stores the credential

Authentication:

  1. POST /v1/auth/passkey/login/begin with a handle hint
  2. Server builds assertion options, serializes PasskeyAuthentication state into state_data
  3. Browser runs navigator.credentials.get()
  4. POST /v1/auth/passkey/login/finish verifies 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_ID is emulator.ca in production, localhost in development
  • PASSKEY_RP_NAME is Emulator.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.


See also: Journey Day 11: Identity, SEO, and Polish