Back to Articles
2026 / 01
| 3 min read

Deep Dive: Passkey Authentication for a Retro BBS

WebAuthn meets 1980s aesthetics—implementing FIDO2 passkeys and biometric auth for a system that pretends to be a 300-baud bulletin board.

emulator authentication passkeys webauthn security

Deep Dive: Passkey Authentication for a Retro BBS

WebAuthn meets 1980s aesthetics


There’s something deeply funny about implementing cutting-edge passwordless authentication for a system that’s pretending to be a 300-baud BBS. But here we are: FIDO2 passkeys, biometric unlock, and hardware security keys — all to let you post messages on a fake bulletin board.

The actual reason is sensible: OAuth providers (GitHub, Discord) are fine, but some people don’t want to link accounts. And passwords? Passwords are terrible. Passkeys solve this properly.

How Passkeys Work (Briefly)

Passkeys are based on public-key cryptography:

  1. During registration, your device creates a key pair (private + public)
  2. The private key never leaves your device
  3. The server only stores the public key
  4. During login, the server sends a challenge
  5. Your device signs the challenge with the private key
  6. The server verifies the signature with the public key

No password transmitted. No password stored. No password to steal.

┌────────────┐                       ┌────────────┐
│   Browser  │                       │   Server   │
│            │──── Registration ────►│            │
│            │     (public key)      │ Store pub  │
│ Has priv   │                       │ key only   │
│ key only   │◄──── Challenge ───────│            │
│            │                       │            │
│            │──── Signed Response──►│ Verify sig │
│            │                       │ with pub   │
└────────────┘                       └────────────┘

The Client Side

I’m using SimpleWebAuthn’s browser library because rolling your own WebAuthn is how you introduce security vulnerabilities:

// lib/passkey-client.ts
import {
  startRegistration,
  startAuthentication,
} from '@simplewebauthn/browser';

export class PasskeyClient {
  private apiBase: string;
  
  constructor(apiBase: string = '/api/passkey') {
    this.apiBase = apiBase;
  }
  
  async register(): Promise<RegisterResult> {
    // Step 1: Get registration options from server
    const optionsRes = await fetch(`${this.apiBase}/register/options`, {
      method: 'POST',
      credentials: 'include',
    });
    const options = await optionsRes.json();
    
    // Step 2: Create credential (this prompts the user)
    // Browser shows biometric/PIN prompt
    const credential = await startRegistration(options);
    
    // Step 3: Send credential to server for verification
    const verifyRes = await fetch(`${this.apiBase}/register/verify`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify(credential),
    });
    
    return verifyRes.json();
  }
  
  async authenticate(): Promise<AuthResult> {
    // Step 1: Get authentication options
    const optionsRes = await fetch(`${this.apiBase}/auth/options`, {
      method: 'POST',
      credentials: 'include',
    });
    const options = await optionsRes.json();
    
    // Step 2: Get credential (biometric/PIN prompt)
    const credential = await startAuthentication(options);
    
    // Step 3: Verify with server
    const verifyRes = await fetch(`${this.apiBase}/auth/verify`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify(credential),
    });
    
    return verifyRes.json();
  }
}

The startRegistration and startAuthentication calls are where the browser takes over. It shows the platform authenticator UI (Touch ID, Face ID, Windows Hello, or a hardware key prompt), collects the credential, and returns it to JavaScript.

The Server Side (Rust)

The server uses the webauthn-rs crate:

// handlers/passkey.rs
use webauthn_rs::prelude::*;

pub struct PasskeyHandler {
    webauthn: Webauthn,
    pool: PgPool,
}

impl PasskeyHandler {
    pub fn new(config: &Config, pool: PgPool) -> Self {
        let rp_id = config.passkey_rp_id.clone();
        let rp_origin = Url::parse(&config.passkey_origin).unwrap();
        
        let builder = WebauthnBuilder::new(&rp_id, &rp_origin)
            .unwrap()
            .rp_name("Emulator.ca BBS");
            
        Self {
            webauthn: builder.build().unwrap(),
            pool,
        }
    }
    
    pub async fn start_registration(
        &self,
        user_id: Uuid,
        user_name: &str,
    ) -> Result<(CreationChallengeResponse, PasskeyRegistration), Error> {
        // Fetch existing credentials for this user (if any)
        let existing = self.get_user_credentials(user_id).await?;
        
        // Generate registration challenge
        let (challenge, reg_state) = self.webauthn.start_passkey_registration(
            user_id,
            user_name,
            user_name,
            Some(existing),
        )?;
        
        // Store reg_state in session/database for verification step
        self.store_registration_state(user_id, &reg_state).await?;
        
        Ok((challenge, reg_state))
    }
    
    pub async fn finish_registration(
        &self,
        user_id: Uuid,
        response: RegisterPublicKeyCredential,
    ) -> Result<Passkey, Error> {
        // Retrieve stored registration state
        let reg_state = self.get_registration_state(user_id).await?;
        
        // Verify the response
        let passkey = self.webauthn.finish_passkey_registration(
            &response,
            &reg_state,
        )?;
        
        // Store the passkey
        self.store_passkey(user_id, &passkey).await?;
        
        Ok(passkey)
    }
}

The key insight: the registration state must be stored temporarily between start_registration and finish_registration. The challenge is time-limited and single-use to prevent replay attacks.

Database Schema

Passkeys need persistent storage:

-- migrations/004_passkey_state_data.sql
CREATE TABLE passkeys (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
    credential_id BYTEA NOT NULL UNIQUE,
    public_key BYTEA NOT NULL,
    counter INTEGER NOT NULL DEFAULT 0,
    transports TEXT[], -- 'usb', 'nfc', 'ble', 'internal'
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_used_at TIMESTAMPTZ,
    
    CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES profiles(id)
);

-- Temporary storage for registration/authentication challenges
CREATE TABLE passkey_challenges (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES profiles(id),
    challenge_type TEXT NOT NULL, -- 'registration' or 'authentication'
    state_data BYTEA NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '5 minutes'
);

-- Clean up expired challenges periodically
CREATE INDEX idx_passkey_challenges_expires ON passkey_challenges(expires_at);

The counter field is important — it’s incremented each time the passkey is used and prevents credential cloning. If the counter ever goes backwards, someone copied the private key.

The UI Component

The auth buttons integrate passkeys alongside OAuth:

// frontend/auth-buttons-lit.ts
@customElement('auth-buttons')
export class AuthButtons extends LitElement {
  @state() private passkeySupported = false;
  @state() private hasPasskey = false;
  @state() private loading = false;
  
  private passkeyClient = new PasskeyClient();
  
  async connectedCallback() {
    super.connectedCallback();
    
    // Check if platform supports passkeys
    this.passkeySupported = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
    
    // Check if user already has a passkey registered
    if (this.userId) {
      this.hasPasskey = await this.checkExistingPasskey();
    }
  }
  
  render() {
    return html`
      <div class="auth-container">
        ${this.passkeySupported ? html`
          <button 
            class="auth-button passkey"
            @click=${this.handlePasskeyAuth}
            ?disabled=${this.loading}
          >
            <span class="icon">🔐</span>
            ${this.hasPasskey ? 'Sign in with Passkey' : 'Create Passkey'}
          </button>
        ` : ''}
        
        <div class="divider">or continue with</div>
        
        <oauth-buttons></oauth-buttons>
      </div>
    `;
  }
  
  private async handlePasskeyAuth() {
    this.loading = true;
    
    try {
      if (this.hasPasskey) {
        const result = await this.passkeyClient.authenticate();
        this.dispatchEvent(new CustomEvent('auth-success', { detail: result }));
      } else {
        const result = await this.passkeyClient.register();
        this.hasPasskey = true;
        this.dispatchEvent(new CustomEvent('passkey-registered', { detail: result }));
      }
    } catch (err) {
      console.error('Passkey auth failed:', err);
      this.dispatchEvent(new CustomEvent('auth-error', { detail: err }));
    } finally {
      this.loading = false;
    }
  }
}

The component auto-detects passkey support. No passkeys on your device? No button shown. Already registered? “Create Passkey” becomes “Sign in with Passkey.”

Platform Authenticator Detection

Different platforms have different capabilities:

// Detect what the platform can do
async function detectAuthenticatorCapabilities(): Promise<AuthenticatorCapabilities> {
  const capabilities: AuthenticatorCapabilities = {
    platformAuthenticator: false,
    conditionalUI: false,
    securityKey: true, // Hardware keys always work
  };
  
  // Platform authenticator (Touch ID, Windows Hello, etc.)
  if (window.PublicKeyCredential) {
    capabilities.platformAuthenticator = await 
      PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
    
    // Conditional UI (autofill passkeys)
    if (PublicKeyCredential.isConditionalMediationAvailable) {
      capabilities.conditionalUI = await 
        PublicKeyCredential.isConditionalMediationAvailable();
    }
  }
  
  return capabilities;
}

macOS with Touch ID? You get biometric. Windows with Hello configured? PIN or face. YubiKey? Tap it. The ceremony adapts.

SysOp Integration

The SysOp BBS backend shows auth status and lets users manage their passkeys:

// backend/sysop/index.ts
async function showAuthStatus(serial: SerialPort): Promise<void> {
  const user = await getCurrentUser();
  
  await serial.writeLine('=== Authentication Status ===\r\n');
  
  if (user.passkeys.length > 0) {
    await serial.writeLine(`Passkeys registered: ${user.passkeys.length}\r\n`);
    for (const pk of user.passkeys) {
      await serial.writeLine(`  - Created: ${formatDate(pk.created_at)}\r\n`);
      await serial.writeLine(`    Last used: ${formatDate(pk.last_used_at)}\r\n`);
    }
  } else {
    await serial.writeLine('No passkeys registered.\r\n');
    await serial.writeLine('Visit settings to add a passkey.\r\n');
  }
  
  if (user.oauth_providers.length > 0) {
    await serial.writeLine(`\r\nLinked accounts:\r\n`);
    for (const provider of user.oauth_providers) {
      await serial.writeLine(`  - ${provider.name}\r\n`);
    }
  }
}

Because nothing says “modern security” like checking your passkey status over a 1200-baud BBS connection.

What I Learned

  1. WebAuthn is well-designed — The browser APIs are clean, the security properties are strong, and the user experience (especially on Apple devices) is excellent.

  2. Challenge storage is the tricky part — You need server-side session state for the challenge, but that state can’t be in a cookie (too big) or in memory (multi-instance deploys). Database it is.

  3. Platform support varies wildly — Chrome and Safari are great. Firefox is okay. Cross-platform passkey sync is still maturing.

  4. Graceful degradation matters — Not everyone has passkey support. The UI needs to handle this without looking broken.

  5. The counter matters — Don’t ignore authentication counter validation. It’s the clone detection mechanism.

The Result

You can now log into a fake 1980s BBS using Face ID. The future is weird, and I love it.


See also: Deep Dive: Microservices Architecture — the backend that validates these passkeys.

See also: Journey Day 11: Passkeys & OAuth — when this shipped.