- Slide 1Building a Production-Grade Passwordless Authentication System
Building a Production-Grade Passwordless Authentication System
Passkeys, OTP codes, and account-enumeration-safe login flows
Ben Houston · 2026-03-18
How to combine passkeys and email OTP codes into a fast, resilient, phishing-resistant authentication system.
Try the demo
https://landofassets.com/demo - Slide 2The Tech Stack
The Tech Stack
App platform
- TanStack Start
- React
- Server Functions
Routing and data
- TanStack Router
- Type-safe file routing
- Tight client/server flow control
Auth and storage
- Drizzle ORM
- SQLite
- JWT for verification links
- WebAuthn for passkeys
- Slide 3Why Traditional Login Flows Fall Short
Why Traditional Login Flows Fall Short
Passwords are a security nightmare
- Users reuse them across sites.
- One breach can unlock multiple accounts.
- Complexity rules push people to forget or write them down.
- The real fix is to remove the password entirely.
Authenticator apps are still a hack
- They require a second device.
- Copying 6-digit codes adds friction.
- They can still be phished in real time.
- Losing the device can lock users out.
- Slide 4The Ideal User Flow
The Ideal User Flow
Primary auth
- Start passkey discovery mode
- Let the device discover the account
- Confirm with Face ID, Touch ID, or PIN
Fallback auth
- Request an OTP code by email
- Enter the code in the same browser session
- Complete login without a password
Why this works
- No secret to remember
- Phishing resistant
- Works even when passkeys are unavailable
- Avoids account enumeration
- Slide 5Logging In With Email OTP Codes
Logging In With Email OTP Codes
Simple but secure
- Send a unique, time-limited 8-character alphanumeric code.
- Access to the email account becomes proof of identity.
- The code must be entered in the same browser session.
- Users can read the email on one device and sign in on another.
Flow
- User requests a login code
- Server creates an auth attempt
- Email delivers the code
- User enters the code
- Server verifies the attempt and signs the user in
- Slide 6Why 8-Character Alphanumeric Codes?
Why 8-Character Alphanumeric Codes?
Security requirement
Authenticator app codes are usually a second factor. In this system, the OTP code is the only factor, so it needs dramatically more entropy.
The math
6-digit numeric:10^6 = 1,000,000combinations8-character alphanumeric:36^8 = 2,821,109,907,456combinations
Why it matters
- Brute-force attacks become computationally infeasible.
- Rate limiting still matters, but the large keyspace adds defense in depth.
- The OTP is strong enough to serve as primary authentication.
- Slide 7OTP Implementation Details
OTP Implementation Details
Storage model
- Generate an 8-character code from
A-Zand0-9. - Hash the code with SHA-256.
- Store the hash in
userAuthAttempts. - Put only the auth attempt reference in the JWT.
- Mark attempts as expiring and one-time use.
const codeHash = hashOTPCode(code); await db.insert(userAuthAttempts).values({ email: user.email, userId: user.id, codeHash, purpose: 'login', expiresAt: new Date(Date.now() + 15 * 60 * 1000), used: false, });Token verification
- Verify the token shape before rendering the page.
- Keep actual code verification on the server.
- Avoid flashing authorized UI to unauthorized users.
export const Route = createFileRoute('/login-via-code/$token')({ beforeLoad: async ({ params }) => { await verifyCodeVerificationToken(params.token); return { tokenValid: true }; }, component: LoginViaCodePage, }); - Generate an 8-character code from
- Slide 8Signup and Recovery Reuse the Same Foundation
Signup and Recovery Reuse the Same Foundation
Signup flow
- User enters name and email.
- Server generates an OTP code.
- Store the code hash with purpose
signup. - Send the email.
- Only create the user after successful verification.
Why this matters
- Email ownership is proven before account creation.
- The flow stays simple and consistent.
- OTP codes also become the recovery path if a user loses a passkey.
- Slide 9Why Passkeys?
Why Passkeys?
Security benefits
- Built on FIDO2 and WebAuthn
- Browser-enforced origin binding
- Strong phishing resistance
- Private key never leaves the device
User experience
- Device generates a key pair during registration.
- Server stores only the public key.
- Server sends a challenge during login.
- Device signs it with the private key.
- User confirms with biometrics or local device auth.
- Slide 10WebAuthn Implementation
WebAuthn Implementation
Stored credential data
- Persist the public key
- Persist the signature counter
- Optionally persist transports
- Never store the private key
export const passkeys = sqliteTable('passkeys', { publicKey: text('public_key').notNull(), counter: integer('counter').notNull().default(0), transports: text('transports'), });Registration rules
- Use
@simplewebauthn/server - Require
residentKey: 'required' - Require
userVerification: 'required' - Prefer platform authenticators for one-click login
authenticatorSelection: { residentKey: 'required', userVerification: 'required', authenticatorAttachment: 'platform', } - Slide 11Security Considerations
Security Considerations
Rate limiting
- Limit send-email requests by IP
- Limit requests by target email
- Add backoff for repeated failures
- Prevent email bombing and token abuse
Account enumeration
- Passkeys in discovery mode avoid email input entirely
- Email login always returns success
- Existing accounts get a code email
- Missing accounts get a notification email instead
Signup safety
- Verify email ownership first
- Create the
Userrecord last - Keep auth attempts purpose-specific
- Treat recovery and signup as first-class flows
- Slide 12The End State
The End State
- Passkeys deliver the fastest and most phishing-resistant experience.
- Email OTP provides a reliable fallback and recovery path.
- Verification-first flows prevent premature account creation.
- Enumeration-safe responses protect user privacy.
- Rate limiting turns a clever auth design into a production-safe one.