Passkeys and WebAuthn with Next.js 15: Build Passwordless Authentication in 2026

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

The password era is ending. Passkeys — built on the WebAuthn standard — let users authenticate with biometrics, device PINs, or hardware keys instead of passwords. In this tutorial, you will build a complete passwordless authentication system with Next.js 15, using the SimpleWebAuthn library to handle the WebAuthn ceremony on both client and server.

What You Will Learn

By the end of this tutorial, you will:

  • Understand how Passkeys and the WebAuthn API work under the hood
  • Set up a Next.js 15 project with TypeScript and Prisma
  • Implement passkey registration (credential creation)
  • Build passwordless login using biometrics or device PIN
  • Handle the complete WebAuthn ceremony (challenge, attestation, assertion)
  • Store and verify credentials in a PostgreSQL database
  • Add fallback authentication for devices without passkey support
  • Deploy a production-ready passwordless auth system

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, generics, async/await)
  • Next.js 15 familiarity (App Router, Server Components, Route Handlers)
  • PostgreSQL running locally or a cloud database (Neon, Supabase, or similar)
  • A modern browser that supports WebAuthn (Chrome, Safari, Firefox, Edge)
  • A device with biometric capability (Touch ID, Face ID, Windows Hello) or a hardware security key

Why Passkeys?

Traditional passwords are the weakest link in web security. Users reuse them, forget them, and fall for phishing attacks that steal them. Passkeys solve all three problems:

FeaturePasswordsPasskeys
Phishing resistantNoYes — bound to origin
Nothing to rememberNoYes — biometric or device PIN
Reuse across sitesCommonImpossible — unique per site
Server breach riskHigh — hashed passwords leakNone — only public keys stored
User experienceFriction-heavyOne tap or glance

Major platforms now support passkeys natively: Apple iCloud Keychain syncs them across devices, Google Password Manager does the same on Android and Chrome, and Windows Hello handles them on Microsoft devices. By mid-2026, over 75% of consumer devices support passkeys out of the box.


How WebAuthn Works

WebAuthn is the W3C standard that powers passkeys. The flow involves three parties:

  1. Relying Party (RP) — your server
  2. Client — the browser
  3. Authenticator — the device biometric sensor or hardware key

Registration Flow

  1. Server generates a challenge (random bytes)
  2. Browser calls navigator.credentials.create() with the challenge
  3. Authenticator creates a public/private key pair, stores the private key securely
  4. Browser sends the public key and attestation back to the server
  5. Server verifies the attestation and stores the public key

Authentication Flow

  1. Server generates a new challenge
  2. Browser calls navigator.credentials.get() with the challenge
  3. Authenticator signs the challenge with the private key
  4. Browser sends the signed assertion to the server
  5. Server verifies the signature against the stored public key

The private key never leaves the device. Even if your database is breached, attackers get only public keys — which are useless without the corresponding private keys locked inside user devices.


Step 1: Project Setup

Create a new Next.js 15 project with TypeScript:

npx create-next-app@latest passkeys-demo --typescript --tailwind --app --src-dir --use-npm
cd passkeys-demo

Install the dependencies:

npm install @simplewebauthn/server @simplewebauthn/browser
npm install @prisma/client
npm install -D prisma @simplewebauthn/types

Here is what each package does:

  • @simplewebauthn/server — Server-side WebAuthn verification (attestation and assertion)
  • @simplewebauthn/browser — Client-side helpers for navigator.credentials calls
  • @prisma/client — Type-safe database ORM
  • @simplewebauthn/types — Shared TypeScript types

Step 2: Database Schema with Prisma

Initialize Prisma:

npx prisma init --datasource-provider postgresql

Update your prisma/schema.prisma to define users and their passkey credentials:

generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id          String       @id @default(cuid())
  email       String       @unique
  name        String?
  credentials Credential[]
  challenges  Challenge[]
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt
}
 
model Credential {
  id                  String   @id @default(cuid())
  credentialId        String   @unique
  credentialPublicKey Bytes
  counter             BigInt   @default(0)
  credentialDeviceType String
  credentialBackedUp  Boolean  @default(false)
  transports          String[] @default([])
  userId              String
  user                User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt           DateTime @default(now())
}
 
model Challenge {
  id        String   @id @default(cuid())
  challenge String
  userId    String?
  user      User?    @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime @default(now())
}

Key design decisions:

  • credentialPublicKey is stored as Bytes — the raw COSE public key
  • counter tracks the signature count to detect cloned authenticators
  • credentialDeviceType distinguishes single-device vs multi-device (synced) credentials
  • credentialBackedUp indicates if the credential is synced via cloud (e.g., iCloud Keychain)
  • transports stores how the authenticator communicates (USB, BLE, NFC, internal)
  • Challenge has an expiration to prevent replay attacks

Run the migration:

npx prisma migrate dev --name init

Create a Prisma client singleton at src/lib/prisma.ts:

import { PrismaClient } from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
 
if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

Step 3: WebAuthn Configuration

Create src/lib/webauthn.ts to centralize your relying party configuration:

import type {
  GenerateRegistrationOptionsOpts,
  GenerateAuthenticationOptionsOpts,
} from "@simplewebauthn/server";
 
// Relying Party configuration
export const rpName = "Passkeys Demo";
export const rpID = process.env.WEBAUTHN_RP_ID || "localhost";
export const origin =
  process.env.WEBAUTHN_ORIGIN || `http://localhost:3000`;
 
// Helper to get expected origins (supports multiple)
export function getExpectedOrigins(): string[] {
  const origins = [origin];
  // Add additional origins for production (e.g., www subdomain)
  if (process.env.WEBAUTHN_ADDITIONAL_ORIGINS) {
    origins.push(
      ...process.env.WEBAUTHN_ADDITIONAL_ORIGINS.split(",")
    );
  }
  return origins;
}

Add environment variables to .env:

DATABASE_URL="postgresql://user:password@localhost:5432/passkeys_demo"
WEBAUTHN_RP_ID="localhost"
WEBAUTHN_ORIGIN="http://localhost:3000"

Important: The rpID must match your domain exactly. For localhost development it should be "localhost". In production, use your domain without the protocol — for example "example.com". Passkeys are bound to this origin and will not work if the rpID changes.


Step 4: Registration API Routes

Create the registration flow with two endpoints: one to generate options and one to verify the response.

Generate Registration Options

Create src/app/api/auth/register/options/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpName, rpID } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { email, name } = await request.json();
 
    if (!email) {
      return NextResponse.json(
        { error: "Email is required" },
        { status: 400 }
      );
    }
 
    // Find or create user
    let user = await prisma.user.findUnique({
      where: { email },
      include: { credentials: true },
    });
 
    if (!user) {
      user = await prisma.user.create({
        data: { email, name: name || email.split("@")[0] },
        include: { credentials: true },
      });
    }
 
    // Get existing credentials to exclude
    const excludeCredentials = user.credentials.map((cred) => ({
      id: cred.credentialId,
      type: "public-key" as const,
      transports: cred.transports as AuthenticatorTransport[],
    }));
 
    // Generate registration options
    const options = await generateRegistrationOptions({
      rpName,
      rpID,
      userName: user.email,
      userDisplayName: user.name || user.email,
      attestationType: "none",
      excludeCredentials,
      authenticatorSelection: {
        residentKey: "preferred",
        userVerification: "preferred",
        authenticatorAttachment: "platform",
      },
      supportedAlgorithmIDs: [-7, -257], // ES256, RS256
    });
 
    // Store the challenge with expiration
    await prisma.challenge.create({
      data: {
        challenge: options.challenge,
        userId: user.id,
        expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
      },
    });
 
    return NextResponse.json({
      options,
      userId: user.id,
    });
  } catch (error) {
    console.error("Registration options error:", error);
    return NextResponse.json(
      { error: "Failed to generate registration options" },
      { status: 500 }
    );
  }
}

Key parameters explained:

  • attestationType: "none" — We do not need the authenticator to prove its identity. Most apps do not require attestation.
  • residentKey: "preferred" — Allows discoverable credentials (passkeys that appear in autofill)
  • userVerification: "preferred" — Requests biometric/PIN verification but does not fail without it
  • authenticatorAttachment: "platform" — Prefers built-in authenticators (Touch ID, Face ID, Windows Hello)
  • supportedAlgorithmIDs — ES256 (ECDSA) and RS256 (RSA) cover nearly all authenticators

Verify Registration

Create src/app/api/auth/register/verify/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID, getExpectedOrigins } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { credential, userId } = await request.json();
 
    // Find the stored challenge
    const storedChallenge = await prisma.challenge.findFirst({
      where: {
        userId,
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: "desc" },
    });
 
    if (!storedChallenge) {
      return NextResponse.json(
        { error: "Challenge expired or not found" },
        { status: 400 }
      );
    }
 
    // Verify the registration response
    const verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge: storedChallenge.challenge,
      expectedOrigin: getExpectedOrigins(),
      expectedRPID: rpID,
      requireUserVerification: false,
    });
 
    if (!verification.verified || !verification.registrationInfo) {
      return NextResponse.json(
        { error: "Verification failed" },
        { status: 400 }
      );
    }
 
    const {
      credential: registrationCredential,
      credentialDeviceType,
      credentialBackedUp,
    } = verification.registrationInfo;
 
    // Store the credential
    await prisma.credential.create({
      data: {
        credentialId: registrationCredential.id,
        credentialPublicKey: Buffer.from(
          registrationCredential.publicKey
        ),
        counter: registrationCredential.counter,
        credentialDeviceType,
        credentialBackedUp,
        transports: credential.response.transports || [],
        userId,
      },
    });
 
    // Clean up used challenge
    await prisma.challenge.delete({
      where: { id: storedChallenge.id },
    });
 
    return NextResponse.json({
      verified: true,
      credentialDeviceType,
      credentialBackedUp,
    });
  } catch (error) {
    console.error("Registration verification error:", error);
    return NextResponse.json(
      { error: "Verification failed" },
      { status: 500 }
    );
  }
}

Step 5: Authentication API Routes

Generate Authentication Options

Create src/app/api/auth/login/options/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { email } = await request.json();
 
    let allowCredentials: {
      id: string;
      type: "public-key";
      transports?: AuthenticatorTransport[];
    }[] = [];
 
    if (email) {
      // If email provided, narrow to that user's credentials
      const user = await prisma.user.findUnique({
        where: { email },
        include: { credentials: true },
      });
 
      if (user) {
        allowCredentials = user.credentials.map((cred) => ({
          id: cred.credentialId,
          type: "public-key" as const,
          transports: cred.transports as AuthenticatorTransport[],
        }));
      }
    }
 
    // Generate authentication options
    const options = await generateAuthenticationOptions({
      rpID,
      userVerification: "preferred",
      allowCredentials:
        allowCredentials.length > 0 ? allowCredentials : undefined,
    });
 
    // Store the challenge
    const user = email
      ? await prisma.user.findUnique({ where: { email } })
      : null;
 
    await prisma.challenge.create({
      data: {
        challenge: options.challenge,
        userId: user?.id || null,
        expiresAt: new Date(Date.now() + 5 * 60 * 1000),
      },
    });
 
    return NextResponse.json({ options });
  } catch (error) {
    console.error("Authentication options error:", error);
    return NextResponse.json(
      { error: "Failed to generate authentication options" },
      { status: 500 }
    );
  }
}

When allowCredentials is empty (no email provided), the browser will show all available passkeys for the domain — this enables the discoverable credential flow where users just tap "Sign in with passkey" without entering an email first.

Verify Authentication

Create src/app/api/auth/login/verify/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID, getExpectedOrigins } from "@/lib/webauthn";
import { cookies } from "next/headers";
 
export async function POST(request: NextRequest) {
  try {
    const { credential } = await request.json();
 
    // Find the credential in the database
    const storedCredential = await prisma.credential.findUnique({
      where: { credentialId: credential.id },
      include: { user: true },
    });
 
    if (!storedCredential) {
      return NextResponse.json(
        { error: "Credential not found" },
        { status: 400 }
      );
    }
 
    // Find the stored challenge
    const storedChallenge = await prisma.challenge.findFirst({
      where: {
        expiresAt: { gt: new Date() },
        OR: [
          { userId: storedCredential.userId },
          { userId: null },
        ],
      },
      orderBy: { createdAt: "desc" },
    });
 
    if (!storedChallenge) {
      return NextResponse.json(
        { error: "Challenge expired or not found" },
        { status: 400 }
      );
    }
 
    // Verify the authentication response
    const verification = await verifyAuthenticationResponse({
      response: credential,
      expectedChallenge: storedChallenge.challenge,
      expectedOrigin: getExpectedOrigins(),
      expectedRPID: rpID,
      credential: {
        id: storedCredential.credentialId,
        publicKey: new Uint8Array(storedCredential.credentialPublicKey),
        counter: Number(storedCredential.counter),
        transports:
          storedCredential.transports as AuthenticatorTransport[],
      },
      requireUserVerification: false,
    });
 
    if (!verification.verified) {
      return NextResponse.json(
        { error: "Authentication failed" },
        { status: 400 }
      );
    }
 
    // Update the counter (important for security)
    await prisma.credential.update({
      where: { id: storedCredential.id },
      data: {
        counter: verification.authenticationInfo.newCounter,
      },
    });
 
    // Clean up the challenge
    await prisma.challenge.delete({
      where: { id: storedChallenge.id },
    });
 
    // Create a session (simplified — use a proper session library in production)
    const cookieStore = await cookies();
    cookieStore.set("session", storedCredential.userId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      maxAge: 60 * 60 * 24 * 7, // 7 days
      path: "/",
    });
 
    return NextResponse.json({
      verified: true,
      user: {
        id: storedCredential.user.id,
        email: storedCredential.user.email,
        name: storedCredential.user.name,
      },
    });
  } catch (error) {
    console.error("Authentication verification error:", error);
    return NextResponse.json(
      { error: "Authentication failed" },
      { status: 500 }
    );
  }
}

Counter verification is critical. The counter increments each time the authenticator is used. If the server sees a counter value lower than what it stored, it means the credential may have been cloned. SimpleWebAuthn handles this check automatically and will throw an error if it detects a cloned authenticator.


Step 6: Client-Side Hooks

Create a custom hook to manage the WebAuthn flow. Create src/hooks/use-passkey.ts:

"use client";
 
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
} from "@simplewebauthn/browser";
import { useState, useCallback } from "react";
 
interface UsePasskeyReturn {
  isSupported: boolean;
  isLoading: boolean;
  error: string | null;
  register: (email: string, name?: string) => Promise<boolean>;
  login: (email?: string) => Promise<boolean>;
}
 
export function usePasskey(): UsePasskeyReturn {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const isSupported = browserSupportsWebAuthn();
 
  const register = useCallback(
    async (email: string, name?: string): Promise<boolean> => {
      setIsLoading(true);
      setError(null);
 
      try {
        // Step 1: Get registration options from server
        const optionsRes = await fetch(
          "/api/auth/register/options",
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ email, name }),
          }
        );
 
        if (!optionsRes.ok) {
          throw new Error("Failed to get registration options");
        }
 
        const { options, userId } = await optionsRes.json();
 
        // Step 2: Create credential via browser API
        const credential = await startRegistration({
          optionsJSON: options,
        });
 
        // Step 3: Verify with server
        const verifyRes = await fetch(
          "/api/auth/register/verify",
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ credential, userId }),
          }
        );
 
        if (!verifyRes.ok) {
          throw new Error("Registration verification failed");
        }
 
        const result = await verifyRes.json();
        return result.verified;
      } catch (err) {
        const message =
          err instanceof Error ? err.message : "Registration failed";
        setError(message);
        return false;
      } finally {
        setIsLoading(false);
      }
    },
    []
  );
 
  const login = useCallback(
    async (email?: string): Promise<boolean> => {
      setIsLoading(true);
      setError(null);
 
      try {
        // Step 1: Get authentication options
        const optionsRes = await fetch("/api/auth/login/options", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email }),
        });
 
        if (!optionsRes.ok) {
          throw new Error("Failed to get authentication options");
        }
 
        const { options } = await optionsRes.json();
 
        // Step 2: Authenticate via browser API
        const credential = await startAuthentication({
          optionsJSON: options,
        });
 
        // Step 3: Verify with server
        const verifyRes = await fetch("/api/auth/login/verify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ credential }),
        });
 
        if (!verifyRes.ok) {
          throw new Error("Authentication failed");
        }
 
        const result = await verifyRes.json();
        return result.verified;
      } catch (err) {
        const message =
          err instanceof Error ? err.message : "Login failed";
        setError(message);
        return false;
      } finally {
        setIsLoading(false);
      }
    },
    []
  );
 
  return { isSupported, isLoading, error, register, login };
}

Step 7: Registration UI Component

Create src/components/passkey-register.tsx:

"use client";
 
import { useState } from "react";
import { usePasskey } from "@/hooks/use-passkey";
 
export function PasskeyRegister() {
  const { isSupported, isLoading, error, register } = usePasskey();
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [success, setSuccess] = useState(false);
 
  if (!isSupported) {
    return (
      <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
        <p className="text-yellow-800">
          Your browser does not support passkeys. Please use a modern
          browser like Chrome, Safari, or Firefox.
        </p>
      </div>
    );
  }
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const result = await register(email, name);
    if (result) {
      setSuccess(true);
    }
  };
 
  if (success) {
    return (
      <div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
        <h3 className="text-lg font-semibold text-green-800">
          Passkey Created!
        </h3>
        <p className="mt-2 text-green-600">
          Your passkey has been registered. You can now sign in
          using your fingerprint, face, or device PIN.
        </p>
      </div>
    );
  }
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label
          htmlFor="email"
          className="block text-sm font-medium text-gray-700"
        >
          Email
        </label>
        <input
          id="email"
          type="email"
          required
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="you@example.com"
        />
      </div>
 
      <div>
        <label
          htmlFor="name"
          className="block text-sm font-medium text-gray-700"
        >
          Display Name (optional)
        </label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="Jane Doe"
        />
      </div>
 
      {error && (
        <div className="rounded-md bg-red-50 p-3">
          <p className="text-sm text-red-700">{error}</p>
        </div>
      )}
 
      <button
        type="submit"
        disabled={isLoading}
        className="w-full rounded-md bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
      >
        {isLoading ? "Creating Passkey..." : "Create Passkey"}
      </button>
    </form>
  );
}

Step 8: Login UI Component

Create src/components/passkey-login.tsx:

"use client";
 
import { useState } from "react";
import { usePasskey } from "@/hooks/use-passkey";
import { useRouter } from "next/navigation";
 
export function PasskeyLogin() {
  const { isSupported, isLoading, error, login } = usePasskey();
  const [email, setEmail] = useState("");
  const router = useRouter();
 
  if (!isSupported) {
    return (
      <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
        <p className="text-yellow-800">
          Your browser does not support passkeys.
        </p>
      </div>
    );
  }
 
  const handleEmailLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    const result = await login(email);
    if (result) {
      router.push("/dashboard");
    }
  };
 
  const handleQuickLogin = async () => {
    const result = await login();
    if (result) {
      router.push("/dashboard");
    }
  };
 
  return (
    <div className="space-y-6">
      {/* Quick login — discoverable credentials */}
      <button
        onClick={handleQuickLogin}
        disabled={isLoading}
        className="w-full rounded-md bg-indigo-600 px-4 py-3 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
      >
        {isLoading ? (
          "Verifying..."
        ) : (
          <span className="flex items-center justify-center gap-2">
            <svg
              className="h-5 w-5"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"
              />
            </svg>
            Sign in with Passkey
          </span>
        )}
      </button>
 
      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <div className="w-full border-t border-gray-300" />
        </div>
        <div className="relative flex justify-center text-sm">
          <span className="bg-white px-2 text-gray-500">
            Or enter your email
          </span>
        </div>
      </div>
 
      {/* Email-based login */}
      <form onSubmit={handleEmailLogin} className="space-y-4">
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="you@example.com"
        />
        <button
          type="submit"
          disabled={isLoading || !email}
          className="w-full rounded-md border border-indigo-600 px-4 py-2 text-indigo-600 hover:bg-indigo-50 disabled:opacity-50"
        >
          Continue with Email
        </button>
      </form>
 
      {error && (
        <div className="rounded-md bg-red-50 p-3">
          <p className="text-sm text-red-700">{error}</p>
        </div>
      )}
    </div>
  );
}

The login component offers two paths:

  1. Quick login — calls login() without an email, triggering the browser to show all available passkeys for the domain
  2. Email-based login — narrows the credential list to a specific user, useful when multiple people share a device

Step 9: Auth Pages

Create the main authentication page at src/app/auth/page.tsx:

"use client";
 
import { useState } from "react";
import { PasskeyRegister } from "@/components/passkey-register";
import { PasskeyLogin } from "@/components/passkey-login";
 
export default function AuthPage() {
  const [mode, setMode] = useState<"login" | "register">("login");
 
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <div className="w-full max-w-md space-y-8 rounded-xl bg-white p-8 shadow-lg">
        <div className="text-center">
          <h1 className="text-2xl font-bold text-gray-900">
            {mode === "login" ? "Welcome Back" : "Create Account"}
          </h1>
          <p className="mt-2 text-gray-600">
            {mode === "login"
              ? "Sign in with your passkey"
              : "Register a new passkey"}
          </p>
        </div>
 
        {mode === "login" ? <PasskeyLogin /> : <PasskeyRegister />}
 
        <div className="text-center">
          <button
            onClick={() =>
              setMode(mode === "login" ? "register" : "login")
            }
            className="text-sm text-indigo-600 hover:text-indigo-500"
          >
            {mode === "login"
              ? "Need an account? Register"
              : "Already have a passkey? Sign in"}
          </button>
        </div>
      </div>
    </div>
  );
}

Step 10: Session Management and Protected Routes

Create a session helper at src/lib/session.ts:

import { cookies } from "next/headers";
import { prisma } from "./prisma";
 
export async function getSession() {
  const cookieStore = await cookies();
  const sessionCookie = cookieStore.get("session");
 
  if (!sessionCookie?.value) {
    return null;
  }
 
  const user = await prisma.user.findUnique({
    where: { id: sessionCookie.value },
    select: {
      id: true,
      email: true,
      name: true,
      credentials: {
        select: {
          id: true,
          credentialDeviceType: true,
          credentialBackedUp: true,
          createdAt: true,
        },
      },
    },
  });
 
  return user;
}

Create middleware to protect routes. Update src/middleware.ts:

import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  const session = request.cookies.get("session");
 
  // Protect dashboard routes
  if (request.nextUrl.pathname.startsWith("/dashboard")) {
    if (!session?.value) {
      return NextResponse.redirect(new URL("/auth", request.url));
    }
  }
 
  // Redirect authenticated users away from auth page
  if (request.nextUrl.pathname === "/auth") {
    if (session?.value) {
      return NextResponse.redirect(
        new URL("/dashboard", request.url)
      );
    }
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/auth"],
};

Create a protected dashboard at src/app/dashboard/page.tsx:

import { getSession } from "@/lib/session";
import { redirect } from "next/navigation";
import { PasskeyManager } from "@/components/passkey-manager";
 
export default async function DashboardPage() {
  const user = await getSession();
 
  if (!user) {
    redirect("/auth");
  }
 
  return (
    <div className="mx-auto max-w-4xl p-8">
      <div className="mb-8">
        <h1 className="text-2xl font-bold">Dashboard</h1>
        <p className="text-gray-600">
          Welcome, {user.name || user.email}
        </p>
      </div>
 
      <div className="rounded-lg border bg-white p-6">
        <h2 className="mb-4 text-lg font-semibold">Your Passkeys</h2>
        <div className="space-y-3">
          {user.credentials.map((cred) => (
            <div
              key={cred.id}
              className="flex items-center justify-between rounded-md border p-3"
            >
              <div>
                <p className="font-medium">
                  {cred.credentialDeviceType === "multiDevice"
                    ? "Synced Passkey"
                    : "Device-bound Passkey"}
                </p>
                <p className="text-sm text-gray-500">
                  Created{" "}
                  {new Date(cred.createdAt).toLocaleDateString()}
                </p>
              </div>
              <div className="flex items-center gap-2">
                {cred.credentialBackedUp && (
                  <span className="rounded-full bg-green-100 px-2 py-1 text-xs text-green-700">
                    Backed up
                  </span>
                )}
              </div>
            </div>
          ))}
        </div>
 
        <PasskeyManager />
      </div>
    </div>
  );
}

Step 11: Passkey Management

Let users add additional passkeys and sign out. Create src/components/passkey-manager.tsx:

"use client";
 
import { usePasskey } from "@/hooks/use-passkey";
import { useRouter } from "next/navigation";
 
export function PasskeyManager() {
  const { isLoading, error, register } = usePasskey();
  const router = useRouter();
 
  const handleAddPasskey = async () => {
    // Fetch current user email from session
    const res = await fetch("/api/auth/me");
    if (!res.ok) return;
    const { email } = await res.json();
    await register(email);
    router.refresh();
  };
 
  const handleSignOut = async () => {
    await fetch("/api/auth/logout", { method: "POST" });
    router.push("/auth");
  };
 
  return (
    <div className="mt-6 flex gap-3">
      <button
        onClick={handleAddPasskey}
        disabled={isLoading}
        className="rounded-md border border-indigo-600 px-4 py-2 text-indigo-600 hover:bg-indigo-50 disabled:opacity-50"
      >
        {isLoading ? "Adding..." : "Add Another Passkey"}
      </button>
      <button
        onClick={handleSignOut}
        className="rounded-md border border-red-300 px-4 py-2 text-red-600 hover:bg-red-50"
      >
        Sign Out
      </button>
      {error && <p className="text-sm text-red-600">{error}</p>}
    </div>
  );
}

Add the supporting API routes:

src/app/api/auth/me/route.ts:

import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
 
export async function GET() {
  const user = await getSession();
  if (!user) {
    return NextResponse.json(
      { error: "Not authenticated" },
      { status: 401 }
    );
  }
  return NextResponse.json({
    id: user.id,
    email: user.email,
    name: user.name,
  });
}

src/app/api/auth/logout/route.ts:

import { NextResponse } from "next/server";
import { cookies } from "next/headers";
 
export async function POST() {
  const cookieStore = await cookies();
  cookieStore.delete("session");
  return NextResponse.json({ success: true });
}

Step 12: Conditional UI for WebAuthn Support

Not all browsers and devices support passkeys equally. Create a utility component to handle graceful degradation at src/components/webauthn-check.tsx:

"use client";
 
import {
  browserSupportsWebAuthn,
  platformAuthenticatorIsAvailable,
} from "@simplewebauthn/browser";
import { useEffect, useState } from "react";
 
interface WebAuthnStatus {
  webauthnSupported: boolean;
  platformSupported: boolean;
  checked: boolean;
}
 
export function useWebAuthnStatus(): WebAuthnStatus {
  const [status, setStatus] = useState<WebAuthnStatus>({
    webauthnSupported: false,
    platformSupported: false,
    checked: false,
  });
 
  useEffect(() => {
    async function check() {
      const webauthnSupported = browserSupportsWebAuthn();
      const platformSupported = webauthnSupported
        ? await platformAuthenticatorIsAvailable()
        : false;
      setStatus({
        webauthnSupported,
        platformSupported,
        checked: true,
      });
    }
    check();
  }, []);
 
  return status;
}

Use this to show appropriate UI:

const { webauthnSupported, platformSupported, checked } =
  useWebAuthnStatus();
 
if (!checked) return <LoadingSpinner />;
 
if (!webauthnSupported) {
  return <PasswordFallbackForm />;
}
 
if (!platformSupported) {
  return <SecurityKeyPrompt />;
}
 
return <PasskeyLogin />;

Testing Your Implementation

Local Testing

Start the development server:

npx prisma migrate dev
npm run dev

Open http://localhost:3000/auth and test:

  1. Register — Enter your email, click "Create Passkey", and authenticate with Touch ID / Windows Hello
  2. Login — Click "Sign in with Passkey" and verify with biometrics
  3. Dashboard — Verify you see your registered passkeys
  4. Add passkey — Add a second passkey from a different device or browser

Testing on Mobile

To test on a mobile device during development:

# Use a tunnel service to expose localhost with HTTPS
npx localtunnel --port 3000

WebAuthn requires a secure context. It only works on https:// origins or localhost. When testing on mobile, you need HTTPS — use a tunnel service or deploy to a staging environment.

Cross-Device Authentication

Passkeys support cross-device authentication via QR codes:

  1. On your desktop browser, start the login flow
  2. Choose "Use a phone or tablet"
  3. Scan the QR code with your mobile device
  4. Authenticate with biometrics on your phone
  5. The desktop browser completes authentication

This works because FIDO2 CTAP (Client to Authenticator Protocol) uses Bluetooth Low Energy to verify that both devices are physically close to each other, preventing remote phishing.


Troubleshooting

"The operation either timed out or was not allowed"

This usually means:

  • The user cancelled the biometric prompt
  • The authenticator timed out (default is 60 seconds)
  • The rpID does not match the current origin

"NotAllowedError: The request is not allowed by the user agent"

Check these common causes:

  • WebAuthn requires a secure context (HTTPS or localhost)
  • The page must be focused — background tabs cannot trigger WebAuthn
  • On Safari, the call must originate from a user gesture (click handler)

Counter mismatch errors

A counter mismatch suggests a cloned authenticator. In development, this can happen if you reset your database without clearing the browser credentials. Clear the passkey from your browser or device settings and re-register.

Credential not found during login

Ensure the credentialId stored in the database matches what the authenticator returns. If you see Base64URL encoding differences, verify that both sides use the same encoding.


Production Deployment Checklist

Before going to production, verify:

  • Set WEBAUTHN_RP_ID to your production domain (e.g., "example.com")
  • Set WEBAUTHN_ORIGIN to your production URL (e.g., "https://example.com")
  • Enable HTTPS — WebAuthn will not work without it
  • Replace the simplified cookie session with a proper session library (iron-session, jose, or similar)
  • Add rate limiting to the authentication endpoints
  • Set up challenge cleanup (cron job to delete expired challenges)
  • Consider adding attestation verification if you need to restrict authenticator types
  • Add password fallback for users on older devices
  • Test across browsers: Chrome, Safari, Firefox, and Edge
  • Test cross-device flows with QR codes

Next Steps

Now that you have passwordless authentication working, consider these enhancements:

  • Conditional UI — Use PublicKeyCredential.isConditionalMediationAvailable() to show passkeys in the browser autofill dropdown, just like saved passwords
  • Password + Passkey hybrid — Allow users to register a password first, then add a passkey later as an upgrade path
  • Account recovery — Implement recovery codes or email-based account recovery for users who lose all their devices
  • Audit logging — Track authentication events (successful logins, failed attempts, new credential registrations)
  • Multi-factor — Combine passkeys with a second factor for high-security operations

Conclusion

You have built a complete passwordless authentication system using Passkeys and WebAuthn with Next.js 15. Your implementation handles:

  • Credential registration with biometric or device PIN verification
  • Passwordless login supporting both discoverable and email-based flows
  • Session management with protected routes and middleware
  • Passkey management allowing users to add multiple credentials
  • Graceful degradation for browsers without WebAuthn support

Passkeys represent the most significant improvement to web authentication in decades. They eliminate passwords entirely, resist phishing by design, and provide a better user experience than any password-based system. As adoption continues to grow through 2026 and beyond, implementing passkeys now positions your application at the forefront of web security.

The complete source code for this tutorial is available as a reference implementation. Start with the basic flow shown here, then layer on the production hardening steps as your application grows.


Want to read more tutorials? Check out our latest tutorial on Motion for React: Build Production-Grade Animations, Gestures, and Transitions.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles