Passkeys, WebAuthn, and Next.js: a Practical Guide

November 23, 2025 | 19 minutes

Passwords are one of the worst parts of the web. We all have at least 100 to remember, they're hard to manage securely, and easy to forget. Passkeys are finally the "no, really" replacement: they're phishing-resistant, can't be reused, and feel like Face ID, Touch ID or your device PIN because they work similarly.

This post explains how passkeys work at a high level, then walks through adding passkey login to a Next.js app using WebAuthn and the excellent @simplewebauthn packages. Our starter app will include passkey registration and sign-in, plus a "username-less" UI experience that lets users pick a saved passkey directly from the browser's account chooser.

You can find the complete working example on GitHub: passkey-nextjs-starter.

Passkey Demo

What is a passkey?

A passkey is a discoverable WebAuthn credential that's managed by an authenticator. Authenticators include platform authenticators such as iCloud Keychain, Google Password Manager, and Windows Hello as well as security keys like YubiKeys. It’s just a public/private keypair bound to:

  • A Relying Party (RP) ID, which is your domain (e.g., example.com)
  • A User handle, which is a stable ID you assign
  • A Credential ID, which is the ID the authenticator uses to find the right key

Why are passkeys stronger than passwords?

The biggest reason why passkeys are stronger than passwords is that they're phishing resistant. The authenticator verifies that the relying party matches the domain of the site. If it doesn't, then the private key won't sign and you can't use the passkey.

Also, the public key is the only key that's stored on your application server, so there's no private key that a hacker could access and potentially unhash.

It also reduces the threat vector if a malicious actor gets a user's passkey. Since passkeys can only be used once and are tied to a specific website, only that one account would be compromised. Regular passwords are often reused, which can compromise many accounts if stolen.

Plus, it's much harder to crack a passkey with brute force than a password.

Two flows: Registration and Authentication

Registration (aka "attestation")

At a high-level, this is what happens when a website creates a passkey:

  1. The server issues a registration challenge.
  2. The browser asks the authenticator to create a credential.
  3. The authenticator returns an attestation containing the new public key.
  4. The server verifies and stores the public key and metadata. It does not store the private key.

The registration challenge is a one-time, random byte string the server generates and embeds in the WebAuthn registration options. It’s proves the browser’s response belongs to this request, from this origin, right now and is not a replay.

Authentication (aka “assertion”)

When authenticating on a website using a passkey, the following happens:

  1. The server issues an authentication challenge.
  2. The authenticator signs the challenge with the credential’s private key.
  3. The server verifies the signature, the origin/RP ID, and the sign counter to prevent cloned keys.

The authentication challenge is similar to the registration challenge. It proves that you still control the private key registered previously with the passkey.

Now that we know why passkeys are a great alternative to passwords and have a basic understanding of how they work, let's build a simple app that uses them for authentication.

What we’ll build with Next.js

Here's an overview of the setup we'll need for our app:

  • App Router (Next.js 13+): Route Handlers under app/api/passkeys/* to manage registering and authenticating passkeys
  • Server: @simplewebauthn/server to generate/verify passkey challenges
  • Client: @simplewebauthn/browser to call WebAuthn APIs
  • DB: Minimal Prisma schema storing user + credentials.
  • Sessions: cookie session via iron-session (or swap for your favorite).

WebAuthn requires HTTPS when you're using it in production. We'll use localhost for testing, though.

1. Install packages

We'll need three main pieces for this setup:

  • @simplewebauthn/server: handles challenge generation and verification on the server
  • `@simplewebauthn/browser: wraps the WebAuthn browser APIs so you don’t have to deal with the raw cryptographic details
  • iron-session: manages temporary session state, so you can store the challenge securely between requests. It lets us avoid spinning up Redis or JWTs just to store a single temporary challenge and instead uses encrypted cookies.

The @simplewebauthn packages abstract away the low-level WebAuthn specification and make it easier to work with.

To persist credentials to a database, we'll also use Prisma for the schema and queries.

1npm i @simplewebauthn/server @simplewebauthn/browser iron-session next prisma @prisma/client
2npm i -D @types/node
3

After installing the dependencies, initialize Prisma. The command below creates a /prisma/schema.prisma file and sets up a local SQLite database by default, which will be perfect for testing locally.

1npx prisma init

2. Prisma schema

Here’s a minimal schema that tracks users and their associated WebAuthn credentials. Put this in the schema.prisma file.

1datasource db {
2  provider = "postgresql"
3  url      = env("DATABASE_URL")
4}
5
6generator client {
7  provider = "prisma-client-js"
8}
9
10model User {
11  id           String       @id @default(cuid())
12  email        String       @unique
13  name         String?
14  credentials  Credential[]
15  createdAt    DateTime     @default(now())
16  updatedAt    DateTime     @updatedAt
17}
18
19model Credential {
20  id                   String   @id @default(cuid())
21  userId               String
22  user                 User     @relation(fields: [userId], references: [id], onDelete: Cascade)
23  credentialId         String   @unique
24  publicKey            String
25  counter              Int
26  transports           String?
27  credentialDeviceType String?
28  backupEligible       Boolean  @default(false)
29  backupState          Boolean  @default(false)
30  createdAt            DateTime @default(now())
31  updatedAt            DateTime @updatedAt
32}

Each User can have multiple registered credentials (e.g., a MacBook Touch ID, an iPhone, and a YubiKey).

Each Credential stores:

  • The credential ID (unique to the authenticator).
  • The public key you’ll use to verify signatures.
  • The sign counter, incremented each time the authenticator is used.
  • Some metadata like transports and backup flags.

In practice, we'll query this table during authentication to look up which keys are valid for a given user.

Create a .env file with the following DATABASE_URL variable. If you're using the default SQLite database that Prisma creates, you can use the value below exactly as is.

1DATABASE_URL="file:./dev.db"

Run the database migration with this command to apply the schema:

1npx prisma db push

Then, generate the Prisma client that we'll use for queries:

1npx prisma generate

Now we're ready to interact with the database from our API routes!

During registration and authentication, the server needs to keep track of a short-lived challenge that was sent to the browser. It needs to match the browser’s signed response to the challenge we issued and to do this, we’ll use iron-session to store that challenge in an encrypted cookie-based session for the starter app.

1// lib/session.ts
2import { SessionOptions } from 'iron-session';
3
4export const sessionOptions: SessionOptions = {
5  cookieName: 'app_session',
6  password: process.env.SESSION_SECRET!, // must be a long, random secret
7  cookieOptions: {
8    secure: process.env.NODE_ENV === 'production',
9    sameSite: 'lax',
10    httpOnly: true
11  }
12};
13
14declare module 'iron-session' {
15  interface IronSessionData {
16    userId?: string;
17    email?: string;
18    isLoggedIn?: boolean;
19    challenge?: string;
20  }
21}

Let's also create a small wrapper utility to access it in our route handlers.

1// lib/withSession.ts
2import { getIronSession, IronSessionData } from 'iron-session';
3import { sessionOptions } from './session';
4
5export async function withSession(req: Request) {
6  const res = new Response();
7  const session = await getIronSession<IronSessionData>(req, res, sessionOptions);
8  return { session, resHeaders: res.headers };
9}

Now, each time the app generates a registration or authentication challenge:

  • We store the options.challenge in session.challenge.
  • When the browser posts its signed response, we read that same value from the session and verify it.
  • Once verification succeeds (or fails), we clear it.

This prevents replay attacks and makes sure we’re verifying the response to the right challenge and not a reused one.

We could technically use JWTs or store the challenge in localStorage or pass it back via a hidden field, but that defeats the purpose because the client could tamper with it. By using a signed, encrypted session cookie, we're keeping the challenge on the server side, where it can’t be modified or replayed.

4. Server configuration

On the server, we need to define the relying party (RP) configuration. This represents who’s actually requesting authentication.

These values are used both when generating and when verifying WebAuthn challenges.

1// lib/webauthn.ts
2export const rpName = "Your App Name";
3export const rpID = process.env.WEBAUTHN_RP_ID!; // e.g., "example.com" or "localhost"
4export const origin = process.env.WEBAUTHN_ORIGIN!; // e.g., "https://app.example.com" or "http://localhost:3000"

The rpName is the human-readable name shown in some browser dialogs.

The rpID must match your domain or one of its subdomains (e.g., if your site is app.example.com, you can safely use example.com as the RP ID).

The origin must exactly match the protocol and hostname your app runs on.

If these ever differ from your deployment domain, like due to a staging environment mismatch, the registration and login processes will fail silently in most browsers.

Store the environment variables in a .env file similar to this:

1DATABASE_URL="file:./dev.db"
2WEBAUTHN_RP_ID=example.com
3WEBAUTHN_ORIGIN=https://app.example.com

5. API routes: Registration & Verification

The registration phase is when we create a new passkey for a user. It’s a two-step handshake between the server, browser, and authenticator, which involves:

  1. Generating a registration challenge and sending it to the browser
  2. Verifying the response from the authenticator and storing the resulting public key.

Generate registration options

Create an API endpoint that will issue the challenge that the authenticator will later sign.

1// app/api/passkeys/generate-registration-options/route.ts
2import { NextResponse } from 'next/server';
3import { withSession } from '@/lib/withSession';
4import { rpID, rpName } from '@/lib/webauthn';
5import { prisma } from '@/lib/prisma';
6import { generateRegistrationOptions } from '@simplewebauthn/server';
7import { TextEncoder } from 'util';
8
9export async function POST(req: Request) {
10  const { session, resHeaders } = await withSession(req);
11  const { email } = await req.json();
12
13  if (!email) {
14    return new NextResponse(JSON.stringify({ error: 'Email is required.' }), {
15      status: 400,
16      headers: { 'Content-Type': 'application/json' }
17    });
18  }
19
20  try {
21    // Find or create the user
22    let user = await prisma.user.findUnique({ where: { email } });
23    if (!user) {
24      user = await prisma.user.create({ data: { email } });
25    }
26
27    // Avoid re-registering the same credential twice
28    const existingCredentials = await prisma.credential.findMany({
29      where: { userId: user.id }
30    });
31
32    // Generate options for the authenticator
33    const options = await generateRegistrationOptions({
34      rpName,
35      rpID,
36      userID: new TextEncoder().encode(user.id.toString()),
37      userName: user.email,
38      userDisplayName: user.email,
39      attestationType: 'none',
40      excludeCredentials: existingCredentials.map((cred) => ({
41        id: cred.credentialId,
42        type: 'public-key',
43        transports: cred.transports ? JSON.parse(cred.transports) : undefined
44      })),
45      authenticatorSelection: {
46        authenticatorAttachment: 'platform', // Force platform authenticator (Touch ID, Face ID)
47        residentKey: 'required',
48        requireResidentKey: true,
49        userVerification: 'preferred'
50      }
51    });
52
53    // Save challenge + user context in session
54    session.challenge = options.challenge;
55    session.userId = user.id; // Store userId for verification step
56    await session.save();
57
58    return new NextResponse(JSON.stringify(options), {
59      headers: {
60        'Content-Type': 'application/json',
61        ...Object.fromEntries(resHeaders)
62      }
63    });
64  } catch (error) {
65    const err = error as Error;
66    console.error('Error generating registration options:', err);
67    return new NextResponse(JSON.stringify({ error: err.message }), {
68      status: 500,
69      headers: { 'Content-Type': 'application/json' }
70    });
71  }
72}

The authenticatorSelection.residentKey property passed to generateRegistrationOptions tells the authenticator to store information about the account that the passkey belongs to. This is what enables passkey authentication without first requiring the user to enter an email address. By making it required, we ensure that only authenticators that support discoverable credentials (passkeys) will be used.

The authenticatorSelection.userVerification property passed to generateRegistrationOptions determines how strict you are in requiring the user to prove they're themself. If it's preferred, the authenticator will use biometric/PIN verification when available but won't fail if it's not supported.

Verify registration response

After the authenticator creates a keypair, the browser returns the signed attestation object.

Create another API endpoint that verifies the signature and saves the resulting credential.

1// app/api/passkeys/verify-registration/route.ts
2import { NextResponse } from 'next/server';
3import { withSession } from '@/lib/withSession';
4import { origin, rpID } from '@/lib/webauthn';
5import { prisma } from '@/lib/prisma';
6import { verifyRegistrationResponse } from '@simplewebauthn/server';
7
8export async function POST(req: Request) {
9  const { session, resHeaders } = await withSession(req);
10  const body = await req.json();
11
12  const expectedChallenge = session.challenge;
13  const userId = session.userId;
14
15  if (!expectedChallenge || !userId) {
16    return new NextResponse(
17      JSON.stringify({ error: 'Session is missing challenge or user ID.' }),
18      { status: 400, headers: { 'Content-Type': 'application/json' } }
19    );
20  }
21
22  try {
23    const verification = await verifyRegistrationResponse({
24      response: body,
25      expectedChallenge,
26      expectedOrigin: origin,
27      expectedRPID: rpID,
28      requireUserVerification: false
29    });
30
31    const { verified, registrationInfo } = verification;
32
33    if (!verified || !registrationInfo) {
34      return new NextResponse(
35        JSON.stringify({ error: 'Could not verify registration.' }),
36        { status: 400, headers: { 'Content-Type': 'application/json' } }
37      );
38    }
39
40    // In @simplewebauthn/server v13+, credential data is nested inside the credential object
41    const {
42      credential: { id: credentialID, publicKey: credentialPublicKey, counter },
43      credentialDeviceType,
44      credentialBackedUp
45    } = registrationInfo;
46
47    // The `transports` property is on the root of the response, not in registrationInfo
48    const transports = (body.response as any).transports || ['internal'];
49
50    // Filter out 'hybrid' to prevent QR code option
51    const platformOnly = transports.filter((t: string) => t === 'internal');
52
53    await prisma.credential.create({
54      data: {
55        userId,
56        credentialId: credentialId,
57        publicKey: Buffer.from(credentialPublicKey).toString('base64url'),
58        counter,
59        credentialDeviceType,
60        backupEligible: credentialBackedUp,
61        backupState: credentialBackedUp,
62        transports: JSON.stringify(
63          platformOnly.length > 0 ? platformOnly : ['internal']
64        )
65      }
66    });
67
68    // Clear the challenge and userId from the session
69    session.challenge = undefined;
70    session.userId = undefined;
71    await session.save();
72
73    return new NextResponse(JSON.stringify({ verified: true }), {
74      headers: {
75        'Content-Type': 'application/json',
76        ...Object.fromEntries(resHeaders)
77      }
78    });
79  } catch (error) {
80    const err = error as Error;
81    console.error('Error verifying registration:', err);
82    // Clear the challenge and userId from the session in case of error
83    session.challenge = undefined;
84    session.userId = undefined;
85    await session.save();
86    return new NextResponse(JSON.stringify({ error: err.message }), {
87      status: 500,
88      headers: { 'Content-Type': 'application/json' }
89    });
90  }
91}

6. Authentication

The authentication flow follows the same idea, but now we’re verifying that the user’s authenticator still holds the private key it registered.

Generate authentication options

Create an API endpoint that generates authentication options.

Note that we're not passing allowCredentials to generateAuthenticationOptions. By omitting this parameter, we enable discoverable credential authentication where the browser will show a picker UI with all passkeys registered for this domain. This creates a "username-less" login experience where users can select their passkey directly from the browser prompt.

1// app/api/passkeys/generate-authentication-options/route.ts
2import { NextResponse } from 'next/server';
3import { withSession } from '@/lib/withSession';
4import { rpID } from '@/lib/webauthn';
5import { prisma } from '@/lib/prisma';
6import { generateAuthenticationOptions } from '@simplewebauthn/server';
7
8export async function POST(req: Request) {
9  const { session, resHeaders } = await withSession(req);
10  const { email } = await req.json();
11
12  if (!email) {
13    return new NextResponse(JSON.stringify({ error: 'Email is required.' }), {
14      status: 400,
15      headers: { 'Content-Type': 'application/json' }
16    });
17  }
18
19  try {
20    const user = await prisma.user.findUnique({ where: { email } });
21    if (!user) {
22      return new NextResponse(
23        JSON.stringify({ error: `User with email ${email} not found.` }),
24        { status: 404, headers: { 'Content-Type': 'application/json' } }
25      );
26    }
27
28    // Generate authentication options without allowCredentials to enable
29    // discoverable credential (passkey) authentication
30    // Omit allowCredentials to enable Safari resident credential discovery.
31    // With a clean passkey state, Safari will prompt for any discoverable credential
32    // on this rpID. Once user confirms, we verify against stored credentials.
33    const options = await generateAuthenticationOptions({
34      rpID,
35      userVerification: 'preferred'
36    });
37
38    session.challenge = options.challenge;
39    session.userId = user.id; // Store userId for verification step
40    await session.save();
41
42    return new NextResponse(JSON.stringify(options), {
43      headers: {
44        'Content-Type': 'application/json',
45        ...Object.fromEntries(resHeaders)
46      }
47    });
48  } catch (error) {
49    const err = error as Error;
50    console.error('Error generating authentication options:', err);
51    return new NextResponse(JSON.stringify({ error: err.message }), {
52      status: 500,
53      headers: { 'Content-Type': 'application/json' }
54    });
55  }
56}

Verify authentication response

Create another API endpoint for verifying the passkey authentication response. This endpoint will:

  • Find the stored credential by credentialId
  • Update the sign counter after successful authentication to detect cloned credentials. If a credential is used with a counter lower than expected, it may have been copied.
  • Requires userVerification: true to ensure biometric/PIN verification occurred
  • After successful verification, creates an authenticated session with the user's details
1// app/api/passkeys/verify-authentication/route.ts
2import { NextResponse } from 'next/server';
3import { withSession } from '@/lib/withSession';
4import { origin, rpID } from '@/lib/webauthn';
5import { prisma } from '@/lib/prisma';
6import { verifyAuthenticationResponse } from '@simplewebauthn/server';
7
8export async function POST(req: Request) {
9  try {
10    const { session, resHeaders } = await withSession(req);
11    const body = await req.json();
12    const expectedChallenge = session.challenge;
13
14    if (!expectedChallenge) {
15      return new NextResponse(
16        JSON.stringify({ error: 'No challenge in session' }),
17        { status: 400, headers: { 'Content-Type': 'application/json' } }
18      );
19    }
20
21    const credentialId = body.id as string;
22    const dbCred = await prisma.credential.findUnique({
23      where: { credentialId },
24      include: { user: true }
25    });
26
27    if (!dbCred) {
28      return new NextResponse(
29        JSON.stringify({ error: 'Unknown credential', credentialId }),
30        { status: 400, headers: { 'Content-Type': 'application/json' } }
31      );
32    }
33
34    const verification = await verifyAuthenticationResponse({
35      response: body,
36      expectedChallenge,
37      expectedOrigin: origin,
38      expectedRPID: rpID,
39      requireUserVerification: true,
40      credential: {
41        id: dbCred.credentialId,
42        publicKey: Buffer.from(dbCred.publicKey, 'base64url'),
43        counter: dbCred.counter,
44        transports: dbCred.transports ? JSON.parse(dbCred.transports) : undefined
45      }
46    });
47
48    const { verified, authenticationInfo } = verification;
49    if (!verified || !authenticationInfo) {
50      return new NextResponse(JSON.stringify({ error: 'Auth failed' }), {
51        status: 401,
52        headers: { 'Content-Type': 'application/json' }
53      });
54    }
55
56    await prisma.credential.update({
57      where: { id: dbCred.id },
58      data: { counter: authenticationInfo.newCounter }
59    });
60
61    session.userId = dbCred.userId;
62    session.email = dbCred.user.email;
63    session.isLoggedIn = true;
64    session.challenge = undefined;
65    await session.save();
66
67    return new NextResponse(
68      JSON.stringify({
69        verified: true,
70        user: { id: dbCred.userId, email: dbCred.user.email }
71      }),
72      {
73        headers: {
74          'Content-Type': 'application/json',
75          ...Object.fromEntries(resHeaders)
76        }
77      }
78    );
79  } catch (error) {
80    console.error('Verify authentication error:', error);
81    return new NextResponse(
82      JSON.stringify({
83        error: 'Internal error',
84        message: error instanceof Error ? error.message : String(error)
85      }),
86      { status: 500, headers: { 'Content-Type': 'application/json' } }
87    );
88  }
89}

7. Client-side UI

On the front-end, we'll call the endpoints above and invoke the WebAuthn browser APIs with @simplewebauthn/browser. The code below implements a three-step flow. Both registration and authentication follow the same pattern of generating options on the server, prompting the user with the browser API (startRegistration or startAuthentication), then verifying the response on the server.

It incorporates progressive feedback to let guide users through the process, error handling and validation, and uses autoComplete="username webauthn" to enable autofill for passkeys in the browser.

1'use client';
2
3import { useState } from 'react';
4import { useRouter } from 'next/navigation';
5import {
6  startRegistration,
7  startAuthentication
8} from '@simplewebauthn/browser';
9
10export default function PasskeysPage() {
11  const router = useRouter();
12  const [email, setEmail] = useState('');
13  const [message, setMessage] = useState('');
14  const [loading, setLoading] = useState(false);
15
16  const handleRegister = async () => {
17    if (!email) {
18      setMessage('Please enter an email address to register.');
19      return;
20    }
21
22    try {
23      setLoading(true);
24      setMessage('Generating registration options...');
25
26      // 1. Generate registration options on the server
27      const regResp = await fetch(
28        '/api/passkeys/generate-registration-options',
29        {
30          method: 'POST',
31          headers: { 'Content-Type': 'application/json' },
32          body: JSON.stringify({ email })
33        }
34      );
35
36      const options = await regResp.json();
37      if (regResp.status !== 200) {
38        throw new Error(
39          options.error || 'Failed to generate registration options'
40        );
41      }
42
43      // 2. Start the registration process on the client
44      setMessage('Please follow the prompt to create your passkey...');
45      const attResp = await startRegistration({ optionsJSON: options });
46
47      // 3. Verify the registration on the server
48      const verifResp = await fetch('/api/passkeys/verify-registration', {
49        method: 'POST',
50        headers: { 'Content-Type': 'application/json' },
51        body: JSON.stringify(attResp)
52      });
53
54      const verificationJSON = await verifResp.json();
55      if (verificationJSON && verificationJSON.verified) {
56        setMessage('✅ Passkey registered successfully! You can now sign in.');
57      } else {
58        throw new Error(
59          verificationJSON.error || 'Registration verification failed'
60        );
61      }
62    } catch (error) {
63      const err = error as Error;
64      setMessage(`❌ Error: ${err.message}`);
65      console.error('Registration error:', err);
66    } finally {
67      setLoading(false);
68    }
69  };
70
71  const handleSignIn = async () => {
72    if (!email) {
73      setMessage('Please enter your email address to sign in.');
74      return;
75    }
76
77    try {
78      setLoading(true);
79      setMessage('Generating authentication options...');
80
81      // 1. Generate authentication options on the server
82      const authResp = await fetch(
83        '/api/passkeys/generate-authentication-options',
84        {
85          method: 'POST',
86          headers: { 'Content-Type': 'application/json' },
87          body: JSON.stringify({ email })
88        }
89      );
90
91      const options = await authResp.json();
92      if (authResp.status !== 200) {
93        throw new Error(
94          options.error || 'Failed to generate authentication options'
95        );
96      }
97
98      // 2. Start the authentication process on the client
99      setMessage('Please follow the prompt to use your passkey...');
100      const asseResp = await startAuthentication({ optionsJSON: options });
101
102      // 3. Verify the authentication on the server
103      const verifResp = await fetch('/api/passkeys/verify-authentication', {
104        method: 'POST',
105        headers: { 'Content-Type': 'application/json' },
106        body: JSON.stringify(asseResp)
107      });
108
109      const verificationJSON = await verifResp.json();
110      if (verificationJSON && verificationJSON.verified) {
111        setMessage('✅ Sign-in successful! Redirecting...');
112        setTimeout(() => router.push('/dashboard'), 500);
113      } else {
114        throw new Error(
115          verificationJSON.error || 'Authentication verification failed'
116        );
117      }
118    } catch (error) {
119      const err = error as Error;
120      if (err.name === 'NotAllowedError') {
121        setMessage('Authentication cancelled.');
122      } else {
123        setMessage(`❌ Error: ${err.message}`);
124      }
125      console.error('Sign-in error:', err);
126    } finally {
127      setLoading(false);
128    }
129  };
130
131  return (
132    <div className='flex flex-col items-center justify-center min-h-screen bg-gray-50 p-4'>
133      <div className='w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md'>
134        <h1 className='text-3xl font-bold text-center text-gray-900'>
135          Passkey Demo
136        </h1>
137
138        <div className='space-y-4'>
139          <div>
140            <label
141              htmlFor='email'
142              className='block text-sm font-medium text-gray-700 mb-2'>
143              Email Address
144            </label>
145            <input
146              id='email'
147              type='email'
148              value={email}
149              onChange={(e) => setEmail(e.target.value)}
150              placeholder='user@example.com'
151              className='w-full px-3 py-2 text-gray-900 placeholder-gray-500 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500'
152              autoComplete='username webauthn'
153              disabled={loading}
154            />
155          </div>
156
157          <div className='flex flex-col space-y-3'>
158            <button
159              onClick={handleRegister}
160              disabled={loading}
161              className='w-full px-4 py-3 font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'>
162              {loading ? 'Processing...' : 'Register Passkey'}
163            </button>
164            <button
165              onClick={handleSignIn}
166              disabled={loading}
167              className='w-full px-4 py-3 font-medium text-indigo-700 bg-indigo-100 rounded-md hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'>
168              {loading ? 'Processing...' : 'Sign In with Passkey'}
169            </button>
170          </div>
171
172          {message && (
173            <div
174              className={`p-4 mt-4 text-sm rounded-md ${
175                message.startsWith('❌')
176                  ? 'bg-red-100 text-red-700 border border-red-200'
177                  : message.startsWith('✅')
178                  ? 'bg-green-100 text-green-700 border border-green-200'
179                  : 'bg-blue-100 text-blue-700 border border-blue-200'
180              }`}>
181              {message}
182            </div>
183          )}
184        </div>
185
186        <div className='text-center text-sm text-gray-600 mt-6'>
187          <p>Use your device's biometric authentication or security key</p>
188        </div>
189      </div>
190    </div>
191  );
192}

8. Logging out

Logging out with our setup is as simple as clearing the user session.

1// app/api/auth/logout/route.ts
2import { NextResponse } from "next/server";
3import { withSession } from "@/lib/withSession";
4
5export async function POST(req: Request) {
6  const { session, resHeaders } = await withSession(req);
7  session.destroy();
8  return new NextResponse(null, { status: 204, headers: resHeaders });
9}

Production hardening checklist

Before shipping to production, make sure you've done the following:

  • Enforce HTTPS (WebAuthn only works on secure origins).
  • Match RP ID/Origin exactly to your domain.
  • Invalidate challenges after use; store them in the session or cache, not the DB.
  • Check sign counters to detect cloned keys.
  • Use SameSite/Lax cookies and CSRF-safe fetch calls.
  • Offer recovery methods (email link, backup passkey).
  • Document recovery flows so users can restore access if they lose all devices.

FAQs

Can I go passwordless from day one?
Yes. Passkeys plus a backup factor (email link) is a great default.

Do passkeys sync across devices?
Yes, within ecosystems (Apple, Google, Microsoft).

Do passkeys support subdomains? RP ID scoping matters—example.com covers subdomains; app.example.com does not.

Complete Example Code

All the code from this tutorial is available in a working Next.js application on GitHub.

The repository includes:

  • Complete API routes for registration and authentication
  • Client-side UI implementation with error handling
  • Prisma schema and database setup
  • Session management with iron-session
  • Ready-to-use configuration files

Clone it, install dependencies, and you'll have a working passkey authentication system to explore and customize for your own projects.

Wrap Up

In short, this setup gives you a smooth, secure, passwordless login with passkeys in Next.js.