Security · Nov 4, 2026 · 7 min read

Add Passkeys to Next.js: Passwordless Auth Guide

Learn to implement WebAuthn passkeys in Next.js using simplewebauthn. This practical tutorial covers registration, login, session management, security considerations, and production deployment for ind

Add Passkeys to Next.js: Passwordless Auth Guide

Why Replace Passwords with Passkeys

Password fatigue affects every developer building user-facing applications. Users reuse credentials across services, creating massive security risks. Passkeys eliminate shared secrets entirely by using public-key cryptography and device biometrics or PINs.

Major platforms now support passkeys natively. Apple, Google, and Microsoft sync them across devices through their password managers. Browsers including Chrome, Safari, and Firefox have excellent support. For indie developers and small teams building SaaS products, implementing passkeys provides a competitive advantage through better security and smoother user experience.

This guide shows you exactly how to add passkeys to a Next.js application using the battle-tested @simplewebauthn library. We will cover the complete flow from database design to production deployment without unnecessary complexity.

How Passkeys Actually Work

Passkeys rely on the WebAuthn API and FIDO2 standards. During registration, your server generates a challenge. The user's browser creates a private-public key pair. The private key stays securely on the user's device. The public key gets sent back to your server and stored.

On login, the server sends a new challenge. The device signs it with the private key. Your server verifies the signature using the stored public key. No password ever touches your systems. The process is inherently phishing-resistant because the browser enforces that the response matches the exact domain.

Modern passkeys also support cross-device authentication. A user can register on their laptop and authenticate on their phone without additional setup when using the same ecosystem.

Project Setup and Dependencies

Start with a fresh Next.js 15 application using the App Router:

npx create-next-app@latest my-app --typescript --tailwind --eslint
cd my-app

Install the required packages for WebAuthn handling and database operations:

npm install @simplewebauthn/server @simplewebauthn/browser
npm install prisma @prisma/client
npm install next-auth

We will use Prisma for data persistence and NextAuth.js for session management. Initialize Prisma with npx prisma init and configure your database. For development, SQLite works well. Production deployments should use PostgreSQL.

Database Schema for Credentials

Passkey storage requires careful schema design. You need to associate credentials with users and handle multiple devices per account. Here is a practical Prisma schema:

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  credentials   Credential[]
  sessions      Session[]
  createdAt     DateTime  @default(now())
}

model Credential {
  id                   String   @id @default(cuid())
  userId               String
  user                 User     @relation(fields: [userId], references: [id])
  credentialId         String   @unique
  publicKey            String
  counter              Int      @default(0)
  deviceType           String?
  backedUp             Boolean  @default(false)
  createdAt            DateTime @default(now())
}

Run npx prisma db push after saving this schema. The counter field prevents replay attacks by tracking signature counts.

Server Actions for Registration

Create an API route at app/api/register/route.ts. The registration happens in two steps: the server generates options, the client registers, then the server verifies.

First, the options generation endpoint:

import { generateRegistrationOptions } from '@simplewebauthn/server';
import { prisma } from '@/lib/prisma';

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

  const options = await generateRegistrationOptions({
    rpName: 'Your App Name',
    rpID: process.env.NEXT_PUBLIC_RPID || 'localhost',
    userID: user.id,
    userName: email,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred'
    }
  });

  // Store challenge temporarily (use Redis in production)
  await prisma.user.update({
    where: { id: user.id },
    data: { currentChallenge: options.challenge }
  });

  return Response.json(options);
}

The client-side registration uses the browser library. Create a React component that calls this endpoint then invokes startRegistration.

Completing Registration on the Client

In your registration form component:

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

async function registerPasskey(email: string, name: string) {
  const optionsResponse = await fetch('/api/register', {
    method: 'POST',
    body: JSON.stringify({ email, name }),
    headers: { 'Content-Type': 'application/json' }
  });
  
  const options = await optionsResponse.json();
  
  const registrationResponse = await startRegistration(options);
  
  const verification = await fetch('/api/register/complete', {
    method: 'POST',
    body: JSON.stringify({
      response: registrationResponse,
      email
    }),
    headers: { 'Content-Type': 'application/json' }
  });
  
  return verification.ok;
}

The verification endpoint on the server uses verifyRegistrationResponse from @simplewebauthn/server and stores the credential data. Make sure to validate the challenge matches what you stored earlier.

Building the Login Flow

Login follows a similar challenge-response pattern. Create /api/login routes for generating authentication options and verifying the response.

The authentication options should discover existing credentials for the user:

const options = await generateAuthenticationOptions({
  rpID: process.env.NEXT_PUBLIC_RPID || 'localhost',
  allowCredentials: user.credentials.map(cred => ({
    id: cred.credentialId,
    type: 'public-key'
  })),
  userVerification: 'preferred'
});

On the client, startAuthentication() prompts the user for biometric verification or PIN depending on their device. The signed response gets sent back to the server for verification using the stored public key.

After successful verification, update the signature counter in your database. This is critical for security.

Managing Sessions Without Passwords

Once authentication succeeds, create a secure session. NextAuth.js works excellently with custom credentials providers. Configure it to accept the WebAuthn verification result.

Use HTTP-only cookies containing a session token. Never expose private keys or sensitive credential data to the client. For additional protection, implement short session lifetimes with refresh tokens.

Always verify the origin and RP ID on every authentication to prevent unauthorized domain attacks.

UX Best Practices for Passkey Flows

Passkey implementation fails when the user experience feels confusing. Provide clear explanations before triggering browser prompts. Show which authentication methods are available: "Sign in with Face ID", "Use Windows Hello", or "Use security key".

  • Graceful fallbacks: Offer email magic links as backup for devices without biometric capability.
  • Progressive disclosure: Explain passkeys benefits during first registration.
  • Device synchronization: Inform users that passkeys sync automatically on Apple and Google devices.
  • Error handling: Provide specific messages for common failures like "User cancelled" or "Invalid challenge".

Test flows on mobile devices early. The experience differs significantly between desktop and iOS/Android.

Security Considerations and Edge Cases

Passkeys significantly reduce attack surface but require correct implementation. Store public keys securely and never log sensitive challenge data. Use rate limiting on authentication endpoints to prevent brute force attempts.

Handle credential revocation when users lose devices. Provide an account recovery flow using email combined with secondary factors. Monitor for abnormal counter values that might indicate cloned credentials.

Production deployments must use HTTPS. The WebAuthn API refuses to work on insecure contexts. Set appropriate rpID values matching your production domain exactly.

Testing Across Browsers and Devices

Comprehensive testing is essential. Test on:

  1. Chrome on desktop with Windows Hello
  2. Safari on macOS and iOS using Touch ID
  3. Firefox with security keys
  4. Android Chrome with fingerprint

Use the WebAuthn debugger extensions available in Chrome DevTools. Simulate different attestation types and error conditions. Verify that counter values increment correctly on each login.

Production Deployment Tips

When deploying to Vercel, set environment variables for NEXT_PUBLIC_RPID to your custom domain. Use a managed database like Neon or Supabase for Prisma. Consider Redis for temporary challenge storage instead of writing to the main user table.

Monitor authentication success rates using analytics. A sudden drop might indicate browser compatibility issues or confusing UX. Log non-sensitive metrics like device type and authentication method.

For larger applications, explore conditional UI mediation to streamline the experience further. Libraries like @github/webauthn-json can simplify some interactions.

Measuring Success and Iteration

After implementation, track key metrics: registration completion rate, login speed compared to password flows, and support ticket volume related to authentication. Users typically complete passkey login 40-60% faster than typing passwords once they are set up.

Continue iterating based on user feedback. Some users prefer traditional passwords initially. Offering both options during transition builds confidence.

The code patterns shown here scale from solo indie projects to small team SaaS products. The same WebAuthn flows work with any backend stack if you maintain the challenge verification logic correctly.

Next Steps and Resources

Start by implementing the registration flow first, then add login. Test incrementally using the browser console to inspect responses. The simplewebauthn documentation provides excellent reference implementations.

Once comfortable with basic flows, explore advanced features like resident keys for passwordless username-less login and multi-factor enhancements.

Passkeys represent a fundamental shift toward better internet security. Implementing them now positions your applications ahead of the curve while delivering immediate UX improvements to your users.

The complete working example with all routes and components is available in the companion repository linked at the end of this article. Adapt the code to match your existing auth architecture and branding.

Keep reading

Related Articles