Creating a Passwordless Login System
Ben Houston • 3 Minutes Read • March 26, 2026
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.

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:
- Collect username + email.
- Send an OTP to the email address.
- Verify OTP to confirm account ownership.
- 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.

Browser API Surface
At the browser layer, it is basically two calls:
navigator.credentials.create()for registrationnavigator.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', }, }, });
Authentication example:
const assertion = await navigator.credentials.get({ publicKey: { challenge, rpId: 'example.com', allowCredentials: [{ id: credentialId, type: 'public-key' }], userVerification: 'preferred', }, });
Two practical notes:
rpIdcan 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
rpIdbindings. - 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:
authenticatorAttachmentfor platform vs roaming key preference.userVerificationto require biometrics/PIN more aggressively.signCountfor some cloned-credential detection scenarios.attestationwhen 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.