BH3D Logo

Creating a Passwordless Login System

Ben Houston3 Minutes ReadMarch 26, 2026

Tags: coding, talks

This is a write-up of my March 26, 2026 talk at ForwardJS.

Passwords have been a mess for a long time. People either reuse weak passwords, or they offload complexity to password managers and still end up with account recovery friction. At a system-design level, passwords are also a shared secret, so a leak at either end is catastrophic.

Why Passwordless?

Passwords are expensive and fragile:

  • They create a high-value breach target, even when hashed.
  • Users routinely choose convenience over security.
  • Traditional 2FA patterns often feel bolted on and frustrating.

Have I Been Pwned

Establishing Identity First: Email OTP

In any user system you still need to establish identity when an account is created. The flow I use is:

  1. Collect username + email.
  2. Send an OTP to the email address.
  3. Verify OTP to confirm account ownership.
  4. Register a passkey on the confirmed account.

For OTP login flows, I recommend an 8-character alphanumeric code plus strong rate limiting. You get a large key space and practical usability.

What Passkeys Are (Mental Model)

The easiest mental model is SSH key auth in your browser:

  • A passkey registration creates a private/public key pair.
  • The private key stays on the user device.
  • The server stores only public-key material.
  • Authentication is challenge/response signing, verified server-side.

Passkeys are scoped to your relying party identity (rpId) and supported across modern browsers and devices.

WebAuthn Logo

Browser API Surface

At the browser layer, it is basically two calls:

  • navigator.credentials.create() for registration
  • navigator.credentials.get() for authentication

Registration example:

const credential = await navigator.credentials.create({
  publicKey: {
    challenge,
    rp: { id: 'example.com', name: 'Example App' },
    user: { id: userId, name: 'alice@example.com', displayName: 'Alice' },
    pubKeyCredParams: [{ alg: -257, type: 'public-key' }],
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'preferred',
    },
  },
});

Create passkey flow

Authentication example:

const assertion = await navigator.credentials.get({
  publicKey: {
    challenge,
    rpId: 'example.com',
    allowCredentials: [{ id: credentialId, type: 'public-key' }],
    userVerification: 'preferred',
  },
});

Authenticate via passkey flow

Two practical notes:

  • rpId can be broad (example.com) or host-specific (app.example.com) depending on your product boundary.
  • rpId: 'localhost' is treated as a secure context for local development.

Server-Side Verification (SimpleWebAuthn)

The browser APIs are straightforward, but server-side verification is where most teams can make mistakes. In the demo, I use SimpleWebAuthn to handle the heavy lifting safely:

  • Decode and validate WebAuthn payload formats.
  • Re-validate challenge, origin, and rpId bindings.
  • Verify signatures against stored credential public keys.
  • Support modern crypto algorithms with solid TypeScript ergonomics.

Could you hand-roll this? Probably, but why?

Advanced WebAuthn Options You Might Need

Most apps can stay with defaults, but these options matter when your security posture gets stricter:

  • authenticatorAttachment for platform vs roaming key preference.
  • userVerification to require biometrics/PIN more aggressively.
  • signCount for some cloned-credential detection scenarios.
  • attestation when hardware provenance policies matter.

Demo Implementation Stack

I made a full passwordless demo user system here based on this tech stack:

  • TanStack Start + Router
  • React + TypeScript
  • Tailwind CSS + shadcn/ui
  • SimpleWebAuthn browser/server packages
  • SQLite + Drizzle ORM

If you want to clone and run it locally, setup steps are documented in the repo README:

Wrap-Up

There is no strong reason to keep shipping password-based auth in new products.

  • Use Email OTP to establish account identity.
  • Use passkeys as the default authentication path.
  • Keep cryptographic verification server-side and well-tested.