DEV Community

Cover image for Implementing Passkeys in Large‑Scale Web Applications
Jordan Davis
Jordan Davis

Posted on

Implementing Passkeys in Large‑Scale Web Applications

Passwords are still the single biggest source of account takeovers, help‑desk tickets, and UX friction. In 2025, passkeys—built on FIDO2/WebAuthn—aren’t a shiny demo anymore; they’re the default for serious apps. If you’re running an enterprise product with millions of users, moving to passkeys pays off on three fronts: massively reduced credential‑stuffing and phishing risk, fewer support costs, and faster sign‑ins that users actually finish.

This guide is the straight talk version of passkey adoption at scale: what they are, where teams get burned, and the patterns that actually survive production traffic.

Background: What Passkeys Change (and Why It Matters)

FIDO2 marries WebAuthn (browser API) with CTAP (client‑to‑authenticator protocol). Instead of passwords, users authenticate with public‑key cryptography:

  • During registration, the client creates a per‑site key pair. The private key never leaves the device; the public key is sent to your server.
  • During authentication, the browser signs a server‑provided challenge with the private key. Your backend verifies the signature with the stored public key—no shared secrets to steal, no password to phish.

Key shifts versus traditional auth:

  • No reusable secret on the wire or in your DB. Password dumps become useless.
  • Phishing resistance via domain binding (the challenge is scoped to your RP ID).
  • Fewer “forgot password” flows, lower support volume, faster conversion.

Implementation Challenges You Need to Plan For

You can ship a proof of concept in a week. You can’t ship a reliable global rollout without tackling these four realities.

1) Scale & Performance

  • Attestation/Assertion payload sizes are bigger than a password post. Your edge and API gateways must handle spiky payloads (hundreds of KB in worst cases).
  • Cold starts in serverless lambdas during high‑traffic auth spikes will hurt. Keep auth paths as warm as you can; pin memory for crypto libs.
  • DB design: Store credential records (userId, credentialId, publicKey, transports, aaguid, signCount/backup‑eligible flags, createdAt, lastUsedAt). Index by userId and credentialId.
  • Rate limiting: Separate unauthenticated (registration/auth challenge) from authenticated routes. Put strict limits around challenge issuance.

2) Browser & Device Compatibility

Passkeys work on all modern browsers, but UX parity is not equal:

  • Platform authenticators (Touch ID, Windows Hello, Android biometrics) behave differently than cross‑platform keys (YubiKey). Your UI must clearly explain options and fallbacks.
  • Synced passkeys (iCloud Keychain, Google Password Manager) improve recovery but introduce multi‑device edge cases (e.g., new device, same account, different transport).
  • Some enterprise environments block BLE/NFC—make sure USB works and expose a “use a different device” path with QR codes (caBLE/Hybrid transport).

3) Integration with Existing Auth Systems

You probably have SSO, MFA, password resets, and device trust. Don’t big‑bang replace it all.

  • Hybrid rollout: Support password + TOTP and passkey side‑by‑side. Encourage passkey creation after successful password login (“Upgrade your sign‑in”).
  • Account linking: Multiple credentials per user. Treat credentials as first‑class resources: create, list, rename, set default, delete.
  • Risk & recovery: Keep break‑glass paths (backup OOB email/SMS or support‑verified reset). Document who can bypass and when.

4) User Onboarding & Support

Users don’t care about FIDO. They care about “does this sign me in faster?”

  • Copy matters: “Use Face ID or a security key” beats “Register a WebAuthn credential.”
  • Progressive disclosure: Start with the simplest recommended path; offer “use a hardware key” as an alternative.
  • Self‑service recovery: Clear UI to add another passkey while the user is signed in. Prompt users to add a second device on day one.

Architecture You Can Actually Run

High‑level flow (registration):

  1. Client requests registration options from your backend.
  2. Backend generates a challenge, chooses RP ID, and returns options.
  3. Client calls navigator.credentials.create(...).
  4. Client POSTs the credential to your backend.
  5. Backend verifies attestation and persists credential metadata.

High‑level flow (authentication):

  1. Client requests assertion options (challenge + allowCredentials).
  2. Client calls navigator.credentials.get(...).
  3. Backend verifies assertion (challenge, origin/RP ID, signature, signCount).
  4. Backend issues a session (cookie) or token (JWT/opaque).

Code Examples (TypeScript/JS)

Below are deliberately low‑level examples to show what’s happening. In production you’ll likely use a vetted library for verification (e.g., @simplewebauthn/server on Node).

Registration (Client)

// registration.ts
type RegistrationOptions = PublicKeyCredentialCreationOptionsJSON;

export async function startRegistration() {
  const res = await fetch("/webauthn/registration/options", {
    method: "POST",
    credentials: "include",
  });
  const options: RegistrationOptions = await res.json();

  // Convert to proper types if your server returns JSON-friendly shapes
  const credential = (await navigator.credentials.create({
    publicKey: {
      challenge: base64urlToBuffer(options.challenge),
      rp: options.rp,
      user: {
        id: base64urlToBuffer(options.user.id),
        name: options.user.name,
        displayName: options.user.displayName,
      },
      pubKeyCredParams: options.pubKeyCredParams,
      authenticatorSelection: options.authenticatorSelection,
      timeout: options.timeout,
      attestation: options.attestation,
    },
  })) as PublicKeyCredential;

  const attestationResponse = credential.response as AuthenticatorAttestationResponse;

  await fetch("/webauthn/registration/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify({
      id: credential.id,
      rawId: bufferToBase64url(credential.rawId),
      type: credential.type,
      response: {
        clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON),
        attestationObject: bufferToBase64url(attestationResponse.attestationObject),
      },
    }),
  });
}

// helpers
function base64urlToBuffer(value: string) {
  const pad = "=".repeat((4 - (value.length % 4)) % 4);
  const base64 = (value + pad).replace(/-/g, "+").replace(/_/g, "/");
  const raw = atob(base64);
  const buffer = new ArrayBuffer(raw.length);
  const bytes = new Uint8Array(buffer);
  for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
  return buffer;
}
function bufferToBase64url(buffer: ArrayBuffer) {
  const bytes = new Uint8Array(buffer);
  let binary = "";
  for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
  const base64 = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return base64;
}
Enter fullscreen mode Exit fullscreen mode

Authentication (Client)

// authentication.ts
type AuthenticationOptions = PublicKeyCredentialRequestOptions;

export async function startAuthentication() {
  const res = await fetch("/webauthn/authentication/options", {
    method: "POST",
    credentials: "include",
  });
  const options: AuthenticationOptions = await res.json();

  const assertion = (await navigator.credentials.get({
    publicKey: {
      challenge: base64urlToBuffer(options.challenge as unknown as string),
      allowCredentials: options.allowCredentials?.map((c: any) => ({
        id: base64urlToBuffer(c.id),
        type: c.type,
        transports: c.transports,
      })),
      timeout: options.timeout,
      userVerification: options.userVerification,
      rpId: options.rpId,
    },
  })) as PublicKeyCredential;

  const response = assertion.response as AuthenticatorAssertionResponse;

  await fetch("/webauthn/authentication/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify({
      id: assertion.id,
      rawId: bufferToBase64url(assertion.rawId),
      type: assertion.type,
      response: {
        clientDataJSON: bufferToBase64url(response.clientDataJSON),
        authenticatorData: bufferToBase64url(response.authenticatorData),
        signature: bufferToBase64url(response.signature),
        userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null,
      },
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

Verification (Server‑Side Sketch, Node/Express)

// server/webauthn.ts (sketch)
import type { Request, Response } from "express";
import { generateChallenge, verifyAttestation, verifyAssertion } from "./webauthn-lib";
import { upsertCredential, getUserById, getCredentialById, updateSignCount } from "./db";

export async function registrationOptions(req: Request, res: Response) {
  const user = await getUserById(req.session.userId);
  const challenge = generateChallenge();
  req.session.challenge = challenge;
  res.json({
    rp: { id: "app.example.com", name: "Example App" },
    user: { id: user.idB64Url, name: user.email, displayName: user.displayName },
    challenge,
    pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
    authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
    attestation: "none",
    timeout: 60000,
  });
}

export async function registrationVerify(req: Request, res: Response) {
  const { ok, credential } = await verifyAttestation(req.body, req.session.challenge, "app.example.com");
  if (!ok) return res.status(400).json({ error: "Invalid attestation" });
  await upsertCredential({
    userId: req.session.userId,
    credentialId: credential.id,
    publicKey: credential.publicKey,
    aaguid: credential.aaguid,
    transports: credential.transports,
    signCount: credential.signCount ?? 0,
  });
  res.sendStatus(200);
}

export async function authenticationOptions(req: Request, res: Response) {
  const user = await getUserById(req.body.userId);
  const creds = user.credentials.map((c: any) => ({ id: c.credentialId, type: "public-key", transports: c.transports }));
  const challenge = generateChallenge();
  req.session.challenge = challenge;
  res.json({ challenge, allowCredentials: creds, timeout: 60000, userVerification: "preferred", rpId: "app.example.com" });
}

export async function authenticationVerify(req: Request, res: Response) {
  const stored = await getCredentialById(req.body.id);
  const { ok, newSignCount } = await verifyAssertion(req.body, req.session.challenge, stored.publicKey, "app.example.com");
  if (!ok) return res.status(400).json({ error: "Invalid assertion" });
  await updateSignCount(stored.credentialId, newSignCount);
  // issue session/JWT here
  res.sendStatus(200);
}
Enter fullscreen mode Exit fullscreen mode

Note: In production, use a robust library for verification logic and origin/RP ID enforcement. Keep your RP ID stable (usually your apex domain).

Best Practices from Enterprise Rollouts

  • Keep RP ID stable. Changing domains later is a migration headache.
  • Make “Add another passkey” a first‑session task. Push users to register a second device to reduce support tickets.
  • Instrument everything. Log registration starts/completions, failure reasons, and authenticator types. Treat passkey conversion like a funnel.
  • Prefer attestation: "none" unless you have a strict hardware security requirement—attestation adds complexity and PII considerations.
  • Offer clear fallback paths. Passkeys should be primary, not only. Keep a secure, rate‑limited recovery channel.
  • Guard challenges. One‑time use, short TTL, tie to IP/user agent as appropriate, and invalidate on success or timeout.
  • Session security still matters. Passkeys kill passwords, not session hijacking. Use HttpOnly, Secure, SameSite cookies, rotating tokens, CSRF protection where needed.

Rollout Strategy That Works

  1. Phase 0 – Internal: Ship to employees on a staging domain; gather device matrix issues.
  2. Phase 1 – Opt‑in: Show “Try passkeys” CTA after successful password login. Track adoption.
  3. Phase 2 – Nudge: Present passkeys as recommended for high‑risk cohorts (admins, power users).
  4. Phase 3 – Default: New accounts register a passkey during onboarding; passwords remain as backup.
  5. Phase 4 – Password‑optional: Offer passkey‑only for orgs that opt in and have backup policies in place.

Key Takeaways

  • Passkeys slash phishing risk and support burden while speeding up sign‑ins.
  • Plan for scale: payload sizes, DB shape, warm paths, and strict rate limits.
  • Ship hybrid first: coexist with current auth; let users upgrade in‑flow.
  • Design the UX: clear copy, progressive options, and self‑service recovery.
  • Instrument the funnel and iterate—treat auth like a product.

Top comments (0)