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.

That sentence sounds absurd until you think about what it actually requires: a server-side challenge, a device signature, and a credential store that survives restarts. The cryptography is the easy part—webauthn-rs handles that. The hard part is state continuity between the browser and the server, and that’s what this article is really about.


The Constraint That Shapes Everything

Passkeys are built on a single invariant: challenges must be single-use, time-boxed, and server-side. The browser can’t generate its own challenge and sign it—that would be pointless. The server must issue the challenge, store it, and verify that the returned signature matches.

This means passkeys require a database. Not “preferably”—require. If the server can’t persist a challenge between the begin and finish calls, it cannot verify the signature, and the whole ceremony fails.

For emulator.ca, I store challenges in passkey_challenges, keyed by the base64url-encoded challenge string. Each record expires after five minutes, and I delete it on use. That single table entry is what keeps replay attacks out.

Two Ceremonies, One Pattern

The WebAuthn story is two ceremonies: registration (create a new credential) and authentication (prove you hold an existing 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

The pattern is identical: issue challenge → store state → receive response → verify → delete challenge. Everything else is bookkeeping.

Why State Lives in the Database

The webauthn-rs library insists on state continuity between begin and finish. It returns an opaque struct—PasskeyRegistration or PasskeyAuthentication—that you must serialize and retrieve intact. Lose it, and the verification fails.

I serialize this state 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 itself becomes the lookup key. When the client sends its response, I extract the challenge from the clientDataJSON, look up the stored state, deserialize it, and hand both to webauthn-rs for verification.

This design survives server restarts and works across multiple instances. The state is in Postgres, not in memory.

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 timeout by itself.

There’s no session cookie magic here. The ceremony state lives on the server, and the client just 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. This is non-negotiable—get it wrong and passkeys silently don’t work.

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.

This keeps the UI honest. No “Sign in with Passkey” button that leads to a cryptic error.

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 just about preventing confusion. Guest sessions have different capabilities and data retention rules. Letting someone register a passkey for GUEST_FOO would create an account with permanent credentials but guest-level trust. The constraint eliminates that class of bug.


The result is a passkey system that sits next to OAuth without special-casing anything. A user can sign in with Face ID, link their GitHub account, and switch between them. The identity model treats passkeys as one authentication shape—cryptographic proof that you hold a credential—and OAuth as another—proof that a provider vouches for you. Both produce the same JWT.

What surprised me most: the cryptography was never the problem. I never had to think about ECDSA or attestation formats—webauthn-rs handles all of that. The real work was state management, error surfacing, and making sure the UI doesn’t lie about what’s possible.

And yes, you can now sign into a fake 1980s BBS with Face ID. The anachronism still makes me smile.


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