All posts

Passkeys and WebAuthn for developers: stop storing passwords you shouldn't

Passwords leak. MFA gets phished. Passkeys bind login to your domain and a device key. Here's how to ship them without breaking legacy users.

~16 min read

Password reset flows are where security goes to die. User clicks a link, picks Summer2024!, reuses it on GitHub, and your bcrypt cost factor never gets a chance to matter.

I have shipped password auth on MERN apps because that is what the template had. I have also watched smart engineers get phished for TOTP codes. WebAuthn passkeys fix a specific failure mode: remote attackers replaying stolen credentials. The private key stays on the device (or synced keychain). The server only stores a public key. A fake login page on another domain cannot complete the ceremony.

This is a practical implementation guide for full-stack developers, not a standards whitepaper. I will cover the ceremonies, working code with @simplewebauthn/server, UX mistakes I made, and what passkeys do not solve.

What are passkeys and WebAuthn?

WebAuthn (Web Authentication) is a browser API for public-key authentication. Passkeys are WebAuthn credentials designed for everyday login: they can sync across devices (Apple iCloud Keychain, Google Password Manager, Windows Hello) or live on a hardware security key.

Terminology that trips people up:

TermMeaning
Relying Party (RP)Your app / server (yourapp.com)
AuthenticatorDevice or key that holds the private key
CredentialKey pair + metadata stored after registration
ChallengeRandom bytes from server, prevents replay
resident key / discoverable credentialPasskey stored on device; enables username-less login
user verification (UV)Biometric or PIN proof

Passkeys are phishing-resistant because the browser enforces origin and rpId. A credential registered for yourapp.com will not sign a challenge from yourapp-login.evil.net, even if the user is fooled into visiting the fake site.

How WebAuthn ceremonies work

WebAuthn has two ceremonies: registration (create credential) and authentication (sign in).

Diagram: WebAuthn registration and authentication ceremonies

Registration (high level)

  1. Server generates registrationOptions with challenge, RP name, RP ID, user handle, and algorithm list.
  2. Browser calls navigator.credentials.create().
  3. Authenticator generates key pair, returns attestation object + client data JSON.
  4. Server verifies attestation (or skips for passkeys), stores credential ID + public key + counter + transports.

Authentication (high level)

  1. Server generates authenticationOptions with challenge and allowed credential IDs (or empty for discoverable passkeys).
  2. Browser calls navigator.credentials.get().
  3. Authenticator signs challenge with private key after user verification.
  4. Server verifies signature, checks origin/rpId/challenge, updates signature counter.

If verification passes, you establish a session the same way you would after password login.

What passkeys changed in 2026

Apple, Google, and Microsoft aligned on cross-platform passkeys. Chrome, Safari, and Edge all support Conditional UI (autofill passkey in the username field). Hardware keys still matter for high-privilege roles, but consumer apps can default to synced passkeys without training users on YubiKeys.

You still need fallback paths. Corporate laptops without biometrics, Linux setups with spotty platform authenticator support, and shared machines all exist.

Implementation: stack and data model

I reach for @simplewebauthn/server on Node and the browser package for client helpers. Raw WebAuthn JSON is verbose; SimpleWebAuthn handles CBOR, base64url, and most spec footguns.

Install:

npm install @simplewebauthn/server @simplewebauthn/browser

Database table (Prisma-style sketch):

model WebAuthnCredential {
  id            String   @id @default(cuid())
  userId        String
  credentialId  Bytes    @unique
  publicKey     Bytes
  counter       BigInt
  deviceType    String   // 'singleDevice' | 'multiDevice'
  backedUp      Boolean
  transports    String[] // 'internal', 'hybrid', 'usb', etc.
  createdAt     DateTime @default(now())
  lastUsedAt    DateTime?
  user          User     @relation(fields: [userId], references: [id])
}

Store challenge server-side (Redis or DB) with TTL 60–120 seconds. Never embed predictable challenges.

Environment config:

// lib/webauthn-config.ts
export const rpName = "My App";
export const rpID = process.env.WEBAUTHN_RP_ID ?? "localhost";
export const origin = process.env.WEBAUTHN_ORIGIN ?? "http://localhost:3000";

Production rpID must be your registrable domain (app.example.com or example.com, not both interchangeably without planning). origin must include scheme and match what the browser sees.

Server code: registration

// app/api/webauthn/register/options/route.ts
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { rpName, rpID } from "@/lib/webauthn-config";
import { getSessionUser, saveChallenge } from "@/lib/auth";

export async function POST() {
  const user = await getSessionUser();
  if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: user.email,
    userDisplayName: user.name,
    userID: new TextEncoder().encode(user.id),
    attestationType: "none", // passkeys: no attestation needed
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
      authenticatorAttachment: "platform", // optional: platform passkeys first
    },
  });

  await saveChallenge(user.id, options.challenge, "registration");

  return Response.json(options);
}

Verify registration response:

// app/api/webauthn/register/verify/route.ts
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { origin, rpID } from "@/lib/webauthn-config";
import { consumeChallenge, saveCredential } from "@/lib/auth";

export async function POST(req: Request) {
  const user = await getSessionUser();
  if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });

  const body = await req.json();
  const expectedChallenge = await consumeChallenge(user.id, "registration");
  if (!expectedChallenge) {
    return Response.json({ error: "Challenge expired" }, { status: 400 });
  }

  const verification = await verifyRegistrationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: true,
  });

  if (!verification.verified || !verification.registrationInfo) {
    return Response.json({ error: "Verification failed" }, { status: 400 });
  }

  const { credential, credentialDeviceType, credentialBackedUp } =
    verification.registrationInfo;

  await saveCredential({
    userId: user.id,
    credentialId: credential.id,
    publicKey: credential.publicKey,
    counter: credential.counter,
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp,
    transports: body.response.transports,
  });

  return Response.json({ verified: true });
}

Client code: registration

// components/RegisterPasskeyButton.tsx
"use client";

import { startRegistration } from "@simplewebauthn/browser";

export function RegisterPasskeyButton() {
  async function register() {
    const optionsRes = await fetch("/api/webauthn/register/options", {
      method: "POST",
    });
    if (!optionsRes.ok) throw new Error("Failed to get options");
    const options = await optionsRes.json();

    const attResp = await startRegistration({ optionsJSON: options });

    const verifyRes = await fetch("/api/webauthn/register/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(attResp),
    });

    if (!verifyRes.ok) throw new Error("Registration verification failed");
    alert("Passkey registered");
  }

  return (
    <button type="button" onClick={() => register().catch(console.error)}>
      Add passkey
    </button>
  );
}

Call startRegistration from a user gesture (button click). Safari blocks the ceremony otherwise.

Server code: authentication

// app/api/webauthn/login/options/route.ts
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { rpID } from "@/lib/webauthn-config";
import { getCredentialsByUserId, saveChallenge } from "@/lib/auth";

export async function POST(req: Request) {
  const { email } = await req.json();
  const user = await findUserByEmail(email);
  if (!user) {
    // Do not leak whether email exists; still return generic options
    return Response.json({ error: "Invalid request" }, { status: 400 });
  }

  const creds = await getCredentialsByUserId(user.id);
  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials: creds.map((c) => ({
      id: c.credentialId,
      transports: c.transports,
    })),
    userVerification: "preferred",
  });

  await saveChallenge(user.id, options.challenge, "authentication");
  return Response.json({ options, userId: user.id });
}

Verify authentication:

// app/api/webauthn/login/verify/route.ts
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { origin, rpID } from "@/lib/webauthn-config";

export async function POST(req: Request) {
  const { body, userId } = await req.json();
  const expectedChallenge = await consumeChallenge(userId, "authentication");
  const credential = await getCredentialById(body.id);

  if (!expectedChallenge || !credential || credential.userId !== userId) {
    return Response.json({ error: "Verification failed" }, { status: 400 });
  }

  const verification = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialId,
      publicKey: credential.publicKey,
      counter: credential.counter,
    },
    requireUserVerification: true,
  });

  if (!verification.verified) {
    return Response.json({ error: "Verification failed" }, { status: 400 });
  }

  await updateCredentialCounter(credential.id, verification.authenticationInfo.newCounter);
  const session = await createSession(userId);
  return Response.json({ verified: true, session });
}

Client code: authentication with Conditional UI

For username-first flows, fetch options after email blur. For passkey-first, use discoverable credentials:

"use client";

import { startAuthentication } from "@simplewebauthn/browser";

export async function loginWithPasskey(email: string) {
  const optionsRes = await fetch("/api/webauthn/login/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email }),
  });
  const { options, userId } = await optionsRes.json();

  const authResp = await startAuthentication({ optionsJSON: options });

  const verifyRes = await fetch("/api/webauthn/login/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ body: authResp, userId }),
  });

  if (!verifyRes.ok) throw new Error("Login failed");
  return verifyRes.json();
}

Enable Conditional UI in supported browsers:

"use client";

import { startAuthentication } from "@simplewebauthn/browser";
import { useEffect } from "react";

export function PasskeyAutofill({ email }: { email: string }) {
  useEffect(() => {
    let cancelled = false;

    async function conditionalLogin() {
      if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
      const available = await PublicKeyCredential.isConditionalMediationAvailable();
      if (!available || cancelled) return;

      const optionsRes = await fetch("/api/webauthn/login/options", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
      });
      if (!optionsRes.ok) return;

      const { options, userId } = await optionsRes.json();

      try {
        const authResp = await startAuthentication({
          optionsJSON: options,
          useBrowserAutofill: true,
        });

        await fetch("/api/webauthn/login/verify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ body: authResp, userId }),
        });
      } catch {
        // User dismissed autofill or no passkey — fall back to password
      }
    }

    if (email.includes("@")) conditionalLogin();
    return () => {
      cancelled = true;
    };
  }, [email]);

  return null;
}

Wire an email input with autocomplete="username webauthn". Browsers show passkeys inline when credentials exist.

Label buttons for humans: "Sign in with fingerprint or Face ID", not "WebAuthn credential assertion."

Challenge storage helper (server)

Keep challenges out of cookies. Redis or a DB row works:

// lib/webauthn-challenge.ts
import { randomBytes } from "crypto";

const CHALLENGE_TTL_MS = 120_000;

type ChallengeRecord = { value: string; expires: number };

const store = new Map<string, ChallengeRecord>(); // use Redis in production

export async function saveChallenge(
  userId: string,
  challenge: string,
  type: "registration" | "authentication"
) {
  const key = `${type}:${userId}`;
  store.set(key, { value: challenge, expires: Date.now() + CHALLENGE_TTL_MS });
}

export async function consumeChallenge(userId: string, type: "registration" | "authentication") {
  const key = `${type}:${userId}`;
  const record = store.get(key);
  store.delete(key);
  if (!record || record.expires < Date.now()) return null;
  return record.value;
}

export function generateUserId(): Uint8Array {
  return randomBytes(32);
}

In production, replace the in-memory Map with Redis SETEX so challenges survive serverless cold starts and horizontal scaling.

Recovery: the part teams ship last and regret first

Passkeys are not magic backup codes. Plan recovery before launch.

Minimum viable recovery:

  1. Multiple passkeys per account (laptop + phone). Encourage adding a second device at registration.
  2. Backup codes (single-use, hashed at rest). Generate 10 at passkey enrollment.
  3. Passkey + password hybrid during migration. Do not delete passwords on day one.
  4. Admin recovery for internal tools with out-of-band identity verification.

What not to do: SMS-only recovery for high-value accounts. You replaced phishable passwords with phishable OTPs.

For developer orgs, require passkeys on GitHub and cloud consoles, keep break-glass hardware keys in a physical safe, and document who can approve account recovery.

UX and migration trade-offs

ApproachProsCons
Passkeys optional, passwords remainLow friction rolloutUsers ignore passkeys
Passkeys default, password fallbackBetter security uptakeMore support tickets
Passkeys only (consumer app)Clean modelExcludes some devices
Hardware key required (prod deploy)Strongest admin securityOnboarding cost

I prefer opt-in prompt after successful password login for B2C, and mandatory for admin/deploy roles in B2B. Nag once, then shut up.

Do not force passkeys-only on day one unless your audience is all on recent Apple/Google stacks. Test on Android mid-range devices and Firefox ESR if your users include enterprise.

Security details easy to get wrong

Signature counter: Persist and monotonically increase. A decreasing counter can indicate cloned authenticator material. SimpleWebAuthn returns newCounter on auth verification. Update it.

HTTPS only: WebAuthn requires secure contexts except localhost. No passkeys on http:// production.

Subdomain rpId: If you register on app.example.com, credentials might not work on admin.example.com depending on rpId choice. Pick parent domain example.com if multiple subdomains need the same passkey.

Attestation: For passkeys, attestationType: "none" is normal. Enterprise deployments sometimes require hardware attestation for compliance. That is a different product decision.

Session after WebAuthn: You still need session fixation protection, CSRF on cookie endpoints, and rotation on privilege change. WebAuthn replaces credential theft, not all appsec.

What passkeys do not fix

  • XSS on your origin. If attacker JS runs on your domain, they can invoke WebAuthn in the user's session. CSP and input sanitization still matter.
  • Device theft with weak OS login. Passkeys follow device security. Stolen unlocked laptop is game over.
  • Account recovery social engineering. Help desk attacks move from "reset password" to "add new passkey."
  • Supply chain compromise. Stolen npm tokens do not care about your auth model. Harden CI separately (npm supply chain guide).

Passkeys shrink the phishing surface. They do not replace defense in depth.

Local development and testing

WebAuthn works on http://localhost without TLS. It does not work on http://192.168.x.x from your phone unless you configure secure contexts and valid certificates. For mobile testing, use a tunnel (ngrok, Cloudflare Tunnel) with HTTPS and set WEBAUTHN_ORIGIN to the tunnel URL.

Chrome DevTools → Application → WebAuthn lets you add virtual authenticators for automated tests. Example Puppeteer flow:

const client = await page.createCDPSession();
await client.send("WebAuthn.enable");
await client.send("WebAuthn.addVirtualAuthenticator", {
  options: {
    protocol: "ctap2",
    transport: "internal",
    hasResidentKey: true,
    hasUserVerification: true,
    isUserVerified: true,
  },
});

For unit tests, mock @simplewebauthn/server verify functions and test your session logic separately. End-to-end WebAuthn tests belong in a small smoke suite, not every PR, unless auth is your product.

Browser and platform support (2026 snapshot)

PlatformPasskey syncNotes
iOS / macOS SafariiCloud KeychainStrong UX; Conditional UI supported
Android ChromeGoogle Password ManagerDevice unlock required
Windows 11Windows HelloWorks with platform authenticator
FirefoxPlatform + security keysSlightly behind on Conditional UI
Linux desktopMixedHave security key or password fallback

Always feature-detect:

export function webAuthnSupported(): boolean {
  return (
    typeof window !== "undefined" &&
    window.PublicKeyCredential !== undefined &&
    typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
      "function"
  );
}

Show passkey UI only when isUserVerifyingPlatformAuthenticatorAvailable() resolves true, but keep password login visible until analytics say you can drop it.

Integrating with NextAuth and existing sessions

If you already use NextAuth.js, you can keep OAuth providers and add passkeys as a custom Credentials-style flow after WebAuthn verification, or use community providers wrapping SimpleWebAuthn. The session cookie you issue after verifyAuthenticationResponse should be identical to password login so middleware stays unchanged.

Pattern I use in App Router apps:

  1. Password or OAuth creates session (existing).
  2. Account settings offers passkey enrollment (registration ceremony).
  3. Login page offers passkey button alongside email/password.
  4. Session TTL and rotation policy unchanged.

Do not store WebAuthn challenges in the JWT session. Keep challenges server-side with short TTL to avoid replay across devices.

Attestation: when none is enough

Consumer passkeys use attestationType: "none" because you trust Apple/Google/Microsoft to manage authenticator integrity, and you only need proof of possession at login.

Enterprise scenarios (regulated finance, government) sometimes require packed attestation from hardware keys with known manufacturer roots. That adds verification complexity and excludes most synced passkeys. Pick one audience:

  • Consumer / SaaS: none, broad device support.
  • Admin / high-privilege: hardware keys + attestation allowlist.

Mixing both on the same rpId is possible but UX-heavy. I split admin auth to a separate subdomain with stricter policy.

What I tried that did not work

Passkeys-only on a MERN side project: Three users on Linux VMs without platform authenticators could not log in. I added password fallback and a "security key" path.

Storing challenges in JWT cookies: Replay window weirdness when users opened two tabs. Moved challenges to server store with TTL.

Skipping user verification: requireUserVerification: false for "smoother UX." Do not. You lose phishing resistance guarantees.

Same rpId for staging and prod: Credentials created on staging broke prod login. Use separate subdomains or environment-specific RP IDs and document it.

Org security: where developers should enforce passkeys first

Priority order for a typical dev team:

  1. GitHub / GitLab organization owners and repo admins
  2. Cloud provider root / billing / IAM admin
  3. Production deploy roles (Vercel, AWS, K8s)
  4. Password managers and email (otherwise attacker resets everything else)

Pair with hardware keys for break-glass admin if you are past 10 engineers. Passkeys sync; hardware keys do not, which is the point for the one key that can delete prod.

Read alongside zero-day response and breach patterns if you are building a 2026 security baseline.

FAQ

Are passkeys the same as WebAuthn?

Passkeys are a user-friendly profile of WebAuthn credentials, usually syncable and discoverable. WebAuthn is the underlying W3C API. You implement WebAuthn; users experience passkeys.

Do I still need passwords if I add passkeys?

During migration, yes. Long term, many apps keep passwords as recovery-only or drop them entirely for consumer apps with strong device coverage. Enterprise apps often keep passwords for legacy clients.

What about users who lose their phone?

Multi-device passkeys (iCloud/Google sync) plus backup codes cover most cases. Support flow should verify identity out-of-band before resetting credentials.

Is SimpleWebAuthn production-ready?

Yes, widely used in Node and Deno apps. Alternatives include Auth0/Firebase passkey modules if you want managed auth. Rolling your own CBOR parsing is a bad use of sprint time.

Does WebAuthn work in iframes?

Generally poorly or not at all for cross-origin iframes. Embed login on your top-level origin. Payment flows have separate carve-outs; auth flows do not.

How does WebAuthn interact with OAuth / "Sign in with Google"?

OAuth federates identity to Google. Passkeys authenticate directly to your RP. You can offer both. Some teams use OAuth for signup and bind a passkey on first session for return visits.

Can I require passkeys for npm and GitHub before my app supports them?

Yes, and you should. Platform passkeys on GitHub, npm, and cloud consoles protect the keys that publish packages and merge PRs. App-level passkeys are a separate rollout.

Ship it this week

  1. Add @simplewebauthn/server and the four routes (register options/verify, login options/verify).
  2. Store credentials and challenges in your existing DB/Redis.
  3. Add "Create passkey" in account settings after login.
  4. Ship backup codes in the same PR.
  5. Enable passkeys on your own GitHub account before mandating them for the team.

Passwords were a workaround for 1970s terminals. Passkeys are not perfect, but they are the first mainstream auth upgrade that actually targets phishing. Implement them like you mean it.


Written by Rohit Singh, software developer in Jaipur. Related: npm supply chain defenses · Zero-day attacks in 2026