
Last week, I caught myself doing something absurd. I was logging into my own admin panel for the hundredth time, typing the same 24-character password I'd memorized years ago, when I realized:
<> I built this thing. I control the server. I wrote the authentication code. Why am I still playing this password theater?/>
That's when I decided to implement passkey authentication. Not because it was trendy, but because I was genuinely tired of passwords. And honestly? The experience changed how I think about web authentication entirely.
The Password Problem Nobody Talks About
We all know passwords are broken. But here's a stat that genuinely surprised me: according to the 2024 Verizon Data Breach Investigations Report, over 80% of hacking-related breaches still involve stolen or weak credentials. After decades of two-factor auth, password managers, and security training, we're still losing the same battle.
The fundamental issue isn't that users are lazy (though some are). It's that passwords are a shared secret. Every time you type your password, you're trusting that:
- The site you're on is legitimate (not a phishing page)
- The connection is secure (MITM attacks exist)
- The server stores it properly (hello, plaintext databases)
- No keylogger is watching (compromised machines)
Passkeys eliminate ALL of these attack vectors. Not reduce. Eliminate.
What Makes Passkeys Different (The Aha Moment)
When I first read about WebAuthn, I thought it was just fancy 2FA. I was wrong. The key insight is this:
<> With passkeys, you never send a secret to the server. Instead, you prove you have the secret without revealing it./>
It's public-key cryptography applied to login. Your device holds a private key that never leaves your device. The server only has your public key. When you authenticate, your device signs a challenge with the private key, and the server verifies it with the public key.
Even if hackers steal everything from the server's database, they can't impersonate you. There's no password to crack, no hash to rainbow-table, no secret to phish.
And here's what sealed the deal for me: passkeys are domain-bound. A passkey created for chyshkala.com literally cannot work on chyshka1a.com (notice the '1' instead of 'l'). Phishing becomes impossible at a cryptographic level.
The Implementation: Less Code Than You'd Think
I expected this to be a weekend project. It took about 4 hours, including database migrations and frontend polish. Here's the architecture I landed on:
Database Schema (Prisma)
WebAuthn requires storing credentials and temporary challenges. Here's my Prisma schema:
1// The admin user (single-user system in my case)
2model AdminUser {
3 id String @id @default(cuid())
4 username String @unique @default("admin")
5 passkeys PasskeyCredential[]
6 createdAt DateTime @default(now())
7 @@map("admin_users")
8}A few notes on the schema decisions:
- counter - This is crucial for replay attack protection. Each authentication increments it, and the server rejects any counter value lower than the stored one.
- transports - Helps the browser know how to communicate with the authenticator (USB, NFC, Bluetooth, or internal).
- Challenges expire in 5 minutes - WebAuthn ceremonies should be quick. Stale challenges are a security risk.
The Server-Side Magic
I used @simplewebauthn/server because hand-rolling WebAuthn is asking for trouble. Here's the core registration flow:
1import {
2 generateRegistrationOptions,
3 verifyRegistrationResponse,
4 type VerifiedRegistrationResponse,
5} from '@simplewebauthn/server';
6
7const rpName = process.env.WEBAUTHN_RP_NAME || 'My App';
8const rpID = process.env.WEBAUTHN_RP_ID || 'localhost';The verification step is where the actual cryptography happens:
1export async function verifyPasskeyRegistration(
2 userId: string,
3 response: RegistrationResponseJSON,
4 name?: string
5) {
6 // Find and validate the challenge
7 const storedChallenge = await prisma.passkeyChallenge.findFirst({
8 where: {The Login Flow
Authentication follows a similar pattern. The browser asks "which passkey?", the server generates a challenge, and the authenticator proves possession of the private key:
1export async function verifyPasskeyAuthentication(
2 response: AuthenticationResponseJSON
3) {
4 // Find the credential being used
5 const credential = await prisma.passkeyCredential.findUnique({
6 where: { credentialId: response.id },
7 include: { adminUser: true },
8 });The Frontend: Surprisingly Simple
The browser-side code is refreshingly minimal. Here's the login button handler:
1import { startAuthentication } from '@simplewebauthn/browser';
2
3const handlePasskeyLogin = async () => {
4 try {
5 // 1. Get authentication options from server
6 const optionsRes = await fetch('/api/passkey/authenticate/options', {
7 method: 'POST',
8 });That's it. The @simplewebauthn/browser library handles all the complexity of converting between the WebAuthn API's ArrayBuffer format and JSON.
Gotchas I Discovered the Hard Way
A few things that tripped me up:
1. MySQL Doesn't Like Binary Unique Indexes
My first schema used Bytes for credentialId. MySQL refused to create a unique index on it. The fix was base64url encoding:
1// Don't do this with MySQL:
2credentialId Bytes @unique // Error!
3
4// Do this instead:
5credentialId String @unique @db.VarChar(512)2. RP ID Must Match Exactly
The Relying Party ID (rpID) must be your domain, exactly. Not https://, just the domain. And it must match between registration and authentication:
1# .env.local
2WEBAUTHN_RP_ID=chyshkala.com
3WEBAUTHN_RP_NAME="Chyshkala Admin"
4WEBAUTHN_ORIGIN=https://chyshkala.comFor local development, use localhost as the RP ID and http://localhost:3000 as the origin.
3. httpOnly Cookies Can't Be Deleted Client-Side
I had logout broken for an embarrassingly long time. My auth cookie was httpOnly: true (as it should be), but I was trying to delete it via JavaScript. The solution: create a server-side logout endpoint that clears the cookie:
1export async function POST() {
2 const response = NextResponse.json({ success: true });
3
4 response.cookies.set('admin_token', '', {
5 httpOnly: true,
6 secure: process.env.NODE_ENV === 'production',
7 sameSite: 'lax',
8 maxAge: 0, // This expires the cookie immediately
9 path: '/',
10 });
11
12 return response;
13}The Result: Faster Than Typing a Password
Now when I visit my admin panel, I click "Sign in with Passkey", touch my MacBook's Touch ID sensor, and I'm in. The whole process takes about 2 seconds, compared to maybe 8-10 seconds of typing and hoping I don't fat-finger my password.
But the real win isn't speed - it's confidence. I no longer worry about:
- Shoulder surfing in coffee shops
- Keyloggers on shared computers
- Phishing attacks targeting my admin credentials
- My password hash being cracked if the database leaks
I kept password authentication as a backup (you never know when you'll need to login from a device without your passkey), but it's relegated to a secondary option below the prominent "Sign in with Passkey" button.
Should You Implement Passkeys?
If you're building anything with user authentication in 2026, I'd strongly consider it. The ecosystem has matured significantly:
- Apple syncs passkeys across devices via iCloud Keychain
- Google syncs them via Google Password Manager
- Windows supports them natively via Windows Hello
- Password managers like 1Password and Bitwarden now support passkeys
The cross-device experience is seamless. I registered a passkey on my MacBook, and it automatically works on my iPhone because they share an iCloud Keychain. Magic.
Resources
If you want to implement this yourself, check out these resources:
- SimpleWebAuthn Documentation (simplewebauthn.dev) - Excellent library with great docs
- WebAuthn Guide (webauthn.guide) - Interactive explanation of the protocol
- passkeys.dev - Industry consortium resources
The code examples in this post are simplified for clarity. In production, add proper error handling, rate limiting, and consider edge cases like credential revocation.
Got questions? Feel free to reach out. I'm always happy to chat about authentication (clearly, given the length of this post).
