Back to Blog
WebAuthnPasskeysAuthenticationPhishingNode.jsNext.jsSecurity

WebAuthn and Passkeys in Node.js and Next.js: Phishing-Resistant Authentication [2026]

Passwords Fail Against Real-Time Phishing

In 2025, the most effective credential-theft operation on the internet was not a data breach. It was a phishing kit sold as a service, operating at scale: attacker-in-the-middle (AiTM) proxies that sat between the victim and a cloned login page, forwarding every keystroke — password, TOTP code, push notification — to the real site in real time. A victim who typed their password and their six-digit code into a convincing clone was logged in by the attacker seconds later. Traditional 2FA did not save them, because the attacker was not replaying stolen codes: they were relaying live ones.

This is why the industry's answer is not a stronger secret. It is phishing-resistant authentication: credentials that are cryptographically bound to your origin, so that a perfect clone of your login page is not just hard to detect — it is structurally unable to receive the credential. WebAuthn, and the passkeys built on top of it, are that answer. They are supported in every major browser, on iOS, Android, Windows, macOS, and Linux, and they remove passwords, OTP, and SMS from your authentication flow entirely.

If you run a Node.js or Next.js product, passkeys are no longer an experimental feature — they are the expected baseline for a security-conscious login. This article covers how the protocol works, how to implement registration and authentication with SimpleWebAuthn in a Next.js App Router app, and the pitfalls that break production rollouts.

What a Passkey Actually Is

A passkey is a public-key credential created on the user's device by a WebAuthn authenticator — a platform authenticator such as Face ID, Windows Hello, or the device's fingerprint sensor, or a roaming authenticator such as a YubiKey.

The key insight: during registration the authenticator generates a key pair. The private key never leaves the authenticator — it cannot be extracted, copied, or exported, not even by the user. The public key is sent to your server and stored. Authentication then works by challenge-response: your server sends a random challenge, the authenticator signs it with the private key, and your server verifies the signature with the stored public key.

Three properties fall out of this design:

  • No shared secret. There is no password, no code, no token that an attacker can steal from a database or intercept in transit. The private key exists in exactly one place, and it never moves.
  • Origin binding. The credential is scoped to a relying party ID (RP ID) — your domain. The browser will only release a signature for the exact origin that matches the RP ID. A clone at acme-login.attacker.com cannot trigger the authenticator, because the RP ID does not match. This is the property that kills AiTM phishing.
  • User verification and presence. The authenticator requires a local gesture — biometric, PIN, or at minimum a touch — before signing. A stolen session token or a remote attacker cannot produce that gesture.

Passkeys are the productized form of WebAuthn: discoverable credentials (also called resident keys) synced across the user's devices, so the "create a passkey" button replaces "create a password" with no security questions, no password managers, no OTP enrollment.

The Two Ceremonies

WebAuthn has two flows, and both are two-step (options → verification):

Registration (creating the credential): your server generates registration options containing the RP ID, a random challenge, and user info; the browser's navigator.credentials.create() invokes the authenticator, which produces a new key pair and returns an attestation response; your server verifies the response — challenge, origin, RP ID, and signature — and stores the public key.

Authentication (proving possession): your server generates authentication options with a fresh challenge; the browser's navigator.credentials.get() asks the authenticator to sign the challenge with the matching private key; your server verifies the signature and the signed client data, including the origin and challenge hash, then updates the credential's counter.

Both flows are time-limited: challenges must be single-use and short-lived, or the whole scheme degrades into a replay attack. We'll implement them with that constraint baked in.

Building Passwordless Auth in Next.js with SimpleWebAuthn

SimpleWebAuthn is the de facto standard library for WebAuthn on Node.js — it handles the fiddly CBOR, COSE, and attestation parsing so you don't have to. This guide uses @simplewebauthn/server and @simplewebauthn/browser (v11+), Prisma, and Next.js App Router route handlers. The same patterns port to Express, Fastify, or any Node.js framework.

1. Dependencies and the Database

bash
pnpm add @simplewebauthn/server @simplewebauthn/browser jose

You need two models: the user and the credentials that belong to them.

prisma
model User {
  id          String              @id @default(cuid())
  email       String              @unique
  name        String?
  credentials PasskeyCredential[]
}

model PasskeyCredential {
  id         String   @id // base64url credential ID, generated by the authenticator
  userId     String
  user       User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  publicKey  String   // base64url COSE public key
  counter    Int      @default(0)
  transports String[] @default([])
  createdAt  DateTime @default(now())
  lastUsedAt DateTime?

  @@index([userId])
}

2. Storing the Challenge (Single-Use, Signed)

The challenge is the anti-replay mechanism, so it must be stored where the client cannot tamper with it and consumed exactly once. A signed, short-lived, httpOnly cookie is a simple and stateless pattern:

ts
// lib/webauthn-challenge.ts
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.CHALLENGE_SECRET);

export async function saveChallenge(challenge: string) {
  const token = await new SignJWT({ challenge })
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("5m")
    .sign(secret);

  (await cookies()).set("wa_challenge", token, {
    httpOnly: true,
    sameSite: "strict",
    secure: process.env.NODE_ENV === "production",
    maxAge: 300,
    path: "/",
  });
}

export async function consumeChallenge(): Promise<string | null> {
  const jar = await cookies();
  const token = jar.get("wa_challenge")?.value;
  jar.delete("wa_challenge"); // single-use: consume on first read
  if (!token) return null;
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload.challenge as string;
  } catch {
    return null; // expired, tampered, or already used
  }
}

Note the sameSite: "strict" cookie and the POST-only endpoints: together they keep the challenge endpoints from being weaponized as a CSRF target.

3. Registration: Options Endpoint

ts
// app/api/webauthn/register/options/route.ts
import { generateRegistrationOptions, isoUint8Array } from "@simplewebauthn/server";
import { saveChallenge } from "@/lib/webauthn-challenge";
import { prisma } from "@/lib/prisma";

const rpID = "acme.com"; // effective domain — no scheme, no port
const rpName = "Acme Corp";
const origin = "https://acme.com";

export async function POST(req: Request) {
  const { email, name } = await req.json();
  const user = await prisma.user.upsert({
    where: { email },
    update: {},
    create: { email, name },
  });

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: user.email,
    userDisplayName: user.name ?? user.email,
    userID: isoUint8Array.fromUTF8String(user.id),
    attestationType: "none", // we only need the public key, not device attestation
    authenticatorSelection: {
      residentKey: "preferred", // allow passkeys (discoverable credentials)
      userVerification: "preferred",
    },
    excludeCredentials: user.credentials.map((c) => ({
      id: c.id, // base64url — v9+ expects strings, not Buffers
      type: "public-key",
    })),
    timeout: 60_000,
  });

  await saveChallenge(options.challenge);
  return Response.json({ options });
}

excludeCredentials prevents the user from registering the same authenticator twice — a copy-paste step that people skip, and then wonder why a second "register" succeeds with the same device.

4. Registration: Verification Endpoint

ts
// app/api/webauthn/register/verify/route.ts
import { verifyRegistrationResponse, isoBase64URL } from "@simplewebauthn/server";
import { consumeChallenge } from "@/lib/webauthn-challenge";

const rpID = "acme.com";
const origin = "https://acme.com";

export async function POST(req: Request) {
  const { email, response } = await req.json();
  const expectedChallenge = await consumeChallenge();
  if (!expectedChallenge) {
    return Response.json({ error: "Challenge missing, expired, or reused" }, { status: 400 });
  }

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: [origin], // allowlist, not a single string
    expectedRPID: rpID,
  });

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

  const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo!;
  const user = await prisma.user.findUnique({ where: { email } });

  await prisma.passkeyCredential.create({
    data: {
      id: credential.id,
      userId: user!.id,
      publicKey: isoBase64URL.fromBuffer(credential.publicKey),
      counter: credential.counter,
    },
  });

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

Store credentialBackedUp if you care about recovery guarantees (a non-backed-up credential is lost when the device is lost), and note credentialDeviceType for analytics.

5. Authentication: Options + Verification

ts
// app/api/webauthn/login/options/route.ts
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { saveChallenge } from "@/lib/webauthn-challenge";

const rpID = "acme.com";

export async function POST(req: Request) {
  const { email } = await req.json();
  const user = await prisma.user.findUnique({ where: { email }, include: { credentials: true } });

  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: "preferred",
    // Omit allowCredentials for discoverable credentials: the authenticator
    // picks the right key itself, which is what enables conditional UI.
    allowCredentials: user
      ? user.credentials.map((c) => ({ id: c.id, type: "public-key" as const }))
      : [],
    timeout: 60_000,
  });

  await saveChallenge(options.challenge);
  return Response.json({ options });
}

The verification endpoint is where the counter check lives:

ts
// app/api/webauthn/login/verify/route.ts
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { consumeChallenge } from "@/lib/webauthn-challenge";
import { createSession } from "@/lib/session";

const rpID = "acme.com";
const origin = "https://acme.com";

export async function POST(req: Request) {
  const { email, response } = await req.json();
  const expectedChallenge = await consumeChallenge();
  if (!expectedChallenge) {
    return Response.json({ error: "Challenge missing, expired, or reused" }, { status: 400 });
  }

  const user = await prisma.user.findUnique({
    where: { email },
    include: { credentials: true },
  });
  const credential = user?.credentials.find((c) => c.id === response.id);
  if (!user || !credential) {
    return Response.json({ error: "Unknown credential" }, { status: 400 });
  }

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: [origin],
    expectedRPID: rpID,
    credential: {
      id: credential.id,
      publicKey: isoBase64URL.toBuffer(credential.publicKey),
      counter: credential.counter,
      transports: credential.transports as AuthenticatorTransport[],
    },
  });

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

  // Cloning detection: a real authenticator increments its counter on every
  // signature. A copied key signs with a stale counter.
  if (authenticationInfo.newCounter > 0 && authenticationInfo.newCounter <= credential.counter) {
    await prisma.passkeyCredential.delete({ where: { id: credential.id } });
    // TODO: alert your security team — possible cloned authenticator
    return Response.json({ error: "Credential revoked" }, { status: 403 });
  }

  await prisma.passkeyCredential.update({
    where: { id: credential.id },
    data: { counter: authenticationInfo.newCounter, lastUsedAt: new Date() },
  });

  await createSession(user.id); // httpOnly, Secure, SameSite cookie — the actual login
  return Response.json({ ok: true });
}

6. The Client Component

tsx
"use client";

import { useState } from "react";
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
  platformAuthenticatorIsAvailable,
} from "@simplewebauthn/browser";

export function PasskeyAuth({ mode }: { mode: "register" | "login" }) {
  const [error, setError] = useState("");

  if (!browserSupportsWebAuthn()) {
    return <p>WebAuthn is not supported in this browser.</p>;
  }

  async function handleClick() {
    try {
      const endpoint = mode === "register" ? "register" : "login";
      const optionsRes = await fetch(`/api/webauthn/${endpoint}/options`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: "user@acme.com" }),
      });
      const { options } = await optionsRes.json();

      const credential =
        mode === "register"
          ? await startRegistration({ optionsJSON: options })
          : await startAuthentication({ optionsJSON: options });

      const verifyRes = await fetch(`/api/webauthn/${endpoint}/verify`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: "user@acme.com", response: credential }),
      });

      if (!verifyRes.ok) {
        const body = await verifyRes.json();
        throw new Error(body.error ?? "Verification failed");
      }
      window.location.href = mode === "register" ? "/dashboard" : "/dashboard";
    } catch (err) {
      setError((err as Error).message);
    }
  }

  return (
    <div>
      <button onClick={handleClick} disabled={!browserSupportsWebAuthn()}>
        {mode === "register" ? "Create a passkey" : "Sign in with a passkey"}
      </button>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}

Conditional UI (autofill) is the upgrade that makes passkeys feel native: with mediation: "conditional" inside startAuthentication, the browser offers the passkey through the native autofill dropdown — no button, no redirect, no password field required:

tsx
useEffect(() => {
  if (!browserSupportsWebAuthn()) return;
  platformAuthenticatorIsAvailable().then((available) => {
    if (!available) return;
    fetch("/api/webauthn/login/options", { method: "POST" })
      .then((r) => r.json())
      .then(async ({ options }) => {
        const credential = await startAuthentication({
          optionsJSON: options,
          mediation: "conditional",
        });
        // ...verify exactly as in handleClick above
      })
      .catch(() => {/* user dismissed the prompt */});
  });
}, []);

The companion input needs the magic autocomplete hint:

tsx
<input
  name="passkey"
  autoComplete="username-webauthn"
  placeholder="Sign in with a passkey"
/>

The Counter: Detecting Cloned Authenticators

The counter is WebAuthn's clone detector, and most implementations ignore it. Each authenticator keeps a monotonic counter that increments on every assertion. A legitimate device always produces a higher counter than the last one you saw. If you ever receive an assertion with a counter equal to or lower than the stored value (and the authenticator supports counters), the private key exists in two places — someone copied it, which is only possible by extracting it from a compromised authenticator or its backup.

The check is three lines, shown above: reject newCounter <= storedCounter (with 0 treated as "unsupported" and skipped), revoke the credential, and page your security team. This is the only signal WebAuthn gives you that a credential was cloned, so wire it into your alerting from day one — not after an incident.

Recovery and Sync: The Honest Trade-offs

Passkeys remove the weakest link in authentication, but they introduce two operational realities you must design for before rollout:

1. There is no password to reset. If a user loses their last authenticator — wiped phone, lost laptop, no sync — they are locked out, and you cannot fix it, because you never held the secret. Ship recovery codes at enrollment (generated once, shown once, stored hashed server-side, same pattern as backup codes), and consider a TOTP fallback for the recovery path. Be honest with yourself: a TOTP fallback reintroduces a phishing vector for the recovery path only — which is why recovery codes are the better default.

2. Synced passkeys move trust to the cloud account. Platform passkeys sync across iCloud Keychain, Google Password Manager, and third-party managers like 1Password. That is a massive UX win — the passkey survives a lost phone — but it means the credential is only as safe as the user's Apple ID or Google account. An attacker who compromises that account gets the synced passkey. This is still strictly better than a password, but for privileged accounts — admins, CI/CD, cloud consoles, your root users — require a hardware security key (non-synced, non-exportable) with residentKey: "discouraged" or enforce device-bound credentials. Segment your risk: convenience passkeys for employees, hardware keys for break-glass access.

Pitfalls That Break Production Rollouts

  • rpID is a domain, not a URL. No scheme, no port, no path. It must be a registrable suffix of the origin — acme.com works for https://app.acme.com; app.acme.com works only for that exact subdomain. Changing rpID later invalidates every existing credential, so set it once and treat it as permanent.
  • Origin allowlist drift. expectedOrigin must be the exact origin list — with the scheme. If your app serves from multiple domains (app, staging, a partner domain), enumerate them explicitly and centralize the constant; a verification that hardcodes one origin breaks staging and silently accepts a new one if you widen it in one place only.
  • localhost is special. Secure contexts are required for WebAuthn; http://localhost is treated as a secure context by Chrome and Firefox (Safari historically less so). Your staging review environment on a LAN IP will not work over plain HTTP — you need HTTPS or a real domain. Test on a tunneled HTTPS URL, not a bare IP.
  • Reusing challenges. If your options and verify endpoints don't consume the challenge atomically, an attacker can replay an intercepted assertion. Single-use, signed, 5-minute TTL — non-negotiable.
  • Verification on the client. Some tutorials check verification.verified in the browser. The browser output is attacker-controlled; every check belongs on the server.
  • Counter not persisted. If you never store and compare newCounter, you lose clone detection entirely.
  • No userVerification policy. Decide between required and preferred and enforce it in verification (requireUserVerification: true for high-security flows). If the UI says "biometric required" but the server accepts a silent touch, your policy is fiction.
  • Passkeys in iframes. WebAuthn is not available in cross-origin iframes — an embedded widget cannot register a passkey for your domain. Design for top-level navigation.
  • Excluding existing credentials on registration. Without excludeCredentials, the same authenticator gets registered twice, and you now have two live credentials that are actually one — confusing audit trails and broken revocation.

The CTO Checklist

  • [ ] Registration and authentication flows use server-side verification only; challenge is single-use, signed, short-TTL.
  • [ ] expectedOrigin and expectedRPID are centralized constants; rpID is frozen and documented.
  • [ ] Credential counters are stored and checked on every assertion; clone detection pages security on a threshold breach.
  • [ ] Recovery codes issued at enrollment; TOTP fallback (if any) is explicitly scoped to recovery, not day-to-day login.
  • [ ] Privileged accounts (admins, CI/CD, cloud consoles) require hardware-bound credentials; employee accounts may use synced passkeys.
  • [ ] Login rate limiting still applies — passkeys kill phishing, not credential-stuffing of any remaining password path (see our API rate limiting guide).
  • [ ] Session cookies after passkey login are httpOnly, Secure, SameSite, and short-lived; logout revokes server-side state.
  • [ ] Browser support matrix documented (iOS Safari, Android Chrome, Windows Hello, macOS Touch ID) and tested with real devices before GA.

Conclusion

Passkeys are the first authentication primitive that makes phishing structurally impossible rather than merely harder: there is no shared secret to steal, and the credential is bound to your origin, so a clone of your login page cannot even request a signature. The implementation cost in a Next.js app is a few route handlers and a client component — SimpleWebAuthn absorbs the protocol complexity — but the security properties come from the details: single-use challenges, strict origin allowlists, persisted counters, and an honest recovery story.

Start with one flow — passwordless registration for new users, keeping existing password logins — measure conversion, and expand. Every credential you move off passwords is one less credential an AiTM phishing kit can relay.

Need a professional review of your WebAuthn rollout? Schedule a security audit — we'll test your registration and assertion flows, challenge handling, counter logic, and recovery paths against real attack scenarios.

Next week: WebSocket Security — protecting real-time APIs in Node.js. From origin checks and token re-validation on upgrade to message rate limiting and closing the cross-site WebSocket hijacking (CSWSH) hole.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Audits led by a senior JavaScript security engineer with 10+ years of experience.