Skip to content

Web Development

Passkeys and WebAuthn in 2026: Replacing Passwords in Your Next.js App

August 17, 202612 min readRasel Hossain
Passkeys and WebAuthn in 2026: Replacing Passwords in Your Next.js App

Quick answer

In 2026, passkeys powered by WebAuthn have become the new standard for Next.js authentication, replacing vulnerable passwords with phishing-resistant, biometric-based credentials that sync across devices and protect users from credential theft.

Passkeys and WebAuthn in 2026: Replacing Passwords in Your Next.js App

Passwords are dying. After more than two decades of being the de facto standard for authentication, the era of "Password123!" is finally coming to an end. I've been building authentication systems for clients since 2020, and in 2026, I can confidently say that passkeys powered by the WebAuthn API are no longer experimental — they are the new baseline for any serious web application.

In this hands-on guide, I'll walk you through everything you need to know to implement passkey-based authentication in a Next.js application. We'll cover the full flow, from registration to login, cross-device sync, fallback strategies for legacy users, and the production-grade best practices I've learned from deploying these systems at scale.

passkeys webauthn 2026 - Image 2

Why Passkeys Matter More Than Ever in 2026

Let me start with the uncomfortable truth: passwords are fundamentally broken. Every week, I see clients dealing with credential stuffing attacks, phishing campaigns, and database leaks that trace back to weak or reused passwords. The numbers from 2025 were staggering — over 4.2 billion credentials were exposed in breaches, and phishing remains the number one attack vector worldwide.

passkeys webauthn 2026 - Image 3

Passkeys, built on the WebAuthn standard (now an official W3C recommendation), solve the core problems that make passwords vulnerable:

  • Phishing-resistant by design: Passkeys are bound to the specific domain where they were created. A passkey registered for yourapp.com simply will not work on yourapp-evil.com.
  • Nothing to steal: The private key never leaves the user's device. Even if your database is breached, attackers get nothing useful.
  • No more password fatigue: Users authenticate with biometrics, device PINs, or hardware security keys.
  • Cross-device sync: Apple's iCloud Keychain, Google Password Manager, and 1Password now sync passkeys seamlessly across devices.

According to the FIDO Alliance, over 75% of global users now have access to a passkey-capable device, and major platforms like Google, Microsoft, GitHub, and Stripe have made passkeys their default authentication method. If your Next.js app doesn't support passkeys in 2026, you're falling behind.

Understanding the WebAuthn Fundamentals

Before we write any code, let's quickly cover the mental model. WebAuthn is built on public-key cryptography. Here's the simplified flow:

  1. The server generates a random challenge and sends it to the browser.
  2. The browser asks an authenticator (Touch ID, Windows Hello, a YubiKey, etc.) to create a key pair.
  3. The private key stays on the device. The public key, along with a credential ID, is sent back to the server.
  4. The server stores the public key and credential ID tied to the user account.
  5. On subsequent logins, the server sends a new challenge, the authenticator signs it with the private key, and the server verifies the signature using the stored public key.

The beauty of this model is that the server only ever holds public keys. Even a complete database breach yields nothing an attacker can use to impersonate users.

Project Setup: Building the Foundation

Let's start with a fresh Next.js 15 project. I'll be using the App Router, TypeScript, and a few carefully chosen libraries that I've battle-tested in production.

npx create-next-app@latest passkey-auth-demo \
  --typescript --app --tailwind --eslint
cd passkey-auth-demo
npm install @simplewebauthn/server @simplewebauthn/browser prisma @prisma/client

The @simplewebauthn library is hands-down the best WebAuthn implementation I've used in production. It abstracts away the painful parts of the spec and works beautifully with both server and client code. If you're doing Next.js authentication in 2026, this is the library you want.

Next, set up your Prisma schema to store credentials:

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

model WebAuthnCredential {
  id                String   @id @default(cuid())
  userId            String
  credentialID      String   @unique
  publicKey         Bytes
  counter           BigInt
  deviceType        String
  backedUp          Boolean
  transports        String?
  createdAt         DateTime @default(now())
  lastUsedAt        DateTime?
  user              User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
}

Notice that we're storing the public key, credential ID, and a counter. The counter is critical — it prevents replay attacks by ensuring each authentication uses a fresh signature.

Implementing the Registration Flow

The registration flow has two phases: a server endpoint that generates the registration options, and a client component that handles the WebAuthn ceremony.

Here's the API route for generating registration options:

// app/api/auth/register/options/route.ts
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

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

  const existingCredentials = await prisma.webAuthnCredential.findMany({
    where: { userId: user.id },
  });

  const options = await generateRegistrationOptions({
    rpName: 'Rasel Hossain Demo',
    rpID: process.env.NEXT_PUBLIC_RP_ID || 'localhost',
    userID: user.id,
    userName: user.email,
    userDisplayName: user.name || user.email,
    attestationType: 'none',
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credentialID,
      transports: cred.transports?.split(',') as AuthenticatorTransport[],
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
    },
    supportedAlgorithmIDs: [-7, -257],
  });

  return NextResponse.json(options);
}

And the verification endpoint:

// app/api/auth/register/verify/route.ts
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function POST(req: Request) {
  const { email, attestationResponse } = await req.json();
  
  const user = await prisma.user.findUnique({ where: { email } });
  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }

  const verification = await verifyRegistrationResponse({
    response: attestationResponse,
    expectedChallenge: attestationResponse.challenge,
    expectedOrigin: process.env.NEXT_PUBLIC_ORIGIN!,
    expectedRPID: process.env.NEXT_PUBLIC_RP_ID!,
  });

  if (!verification.verified || !verification.registrationInfo) {
    return NextResponse.json({ error: 'Verification failed' }, { status: 400 });
  }

  await prisma.webAuthnCredential.create({
    data: {
      userId: user.id,
      credentialID: verification.registrationInfo.credential.id,
      publicKey: verification.registrationInfo.credential.publicKey,
      counter: verification.registrationInfo.credential.counter,
      deviceType: verification.registrationInfo.credentialDeviceType,
      backedUp: verification.registrationInfo.credentialBackedUp,
      transports: verification.registrationInfo.credential.transports?.join(','),
    },
  });

  return NextResponse.json({ verified: true });
}

On the client side, here's a React component that handles the registration ceremony:

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

export function PasskeyRegistration({ email, name }: { email: string; name: string }) {
  const handleRegister = async () => {
    try {
      const optionsResponse = await fetch('/api/auth/register/options', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, name }),
      });
      const options = await optionsResponse.json();

      const attestationResponse = await startRegistration({ optionsJSON: options });

      const verificationResponse = await fetch('/api/auth/register/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, attestationResponse }),
      });

      const verification = await verificationResponse.json();
      if (verification.verified) {
        alert('Passkey registered successfully!');
      }
    } catch (error) {
      console.error('Registration failed:', error);
    }
  };

  return <button onClick={handleRegister}>Create Passkey</button>;
}

Building the Login Flow

The login flow follows a similar pattern. I prefer the conditional UI approach — the modern UX where users see a passkey prompt immediately on the login page if they have one available, but can still type their email as a fallback.

// app/api/auth/login/options/route.ts
import { generateAuthenticationOptions } from '@simplewebauthn/server';

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

  if (!user || user.credentials.length === 0) {
    return NextResponse.json({ error: 'No passkeys found' }, { status: 404 });
  }

  const options = await generateAuthenticationOptions({
    rpID: process.env.NEXT_PUBLIC_RP_ID!,
    allowCredentials: user.credentials.map(cred => ({
      id: cred.credentialID,
      transports: cred.transports?.split(',') as AuthenticatorTransport[],
    })),
    userVerification: 'preferred',
  });

  return NextResponse.json(options);
}

For the verification, you must remember to increment the counter on every successful authentication:

const verification = await verifyAuthenticationResponse({
  response: authenticationResponse,
  expectedChallenge: authenticationResponse.challenge,
  expectedOrigin: process.env.NEXT_PUBLIC_ORIGIN!,
  expectedRPID: process.env.NEXT_PUBLIC_RP_ID!,
  credential: {
    id: credential.credentialID,
    publicKey: credential.publicKey,
    counter: Number(credential.counter),
    transports: credential.transports?.split(',') as AuthenticatorTransport[],
  },
});

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

Handling Cross-Device Sync

One of the questions I get asked most often is about cross-device passkey sync. In 2026, this is mostly handled by the platform:

  • Apple devices: Passkeys sync via iCloud Keychain with end-to-end encryption. As long as the user is signed into the same Apple ID, their passkeys are available everywhere.
  • Android and Chrome: Google Password Manager syncs passkeys across Android devices and Chrome browsers. Chrome 132+ has full passkey sync on desktop too.
  • Password managers: 1Password, Bitwarden, and Dashlane all support passkey sync across platforms.

What you need to do on the server side is straightforward: set residentKey: 'preferred' and userVerification: 'preferred' in your registration options. This enables discoverable credentials, which allow the browser to surface passkeys without the user typing their email first.

The one thing I always recommend to clients is to support multiple passkeys per user. Let users register passkeys from their phone, their laptop, and a hardware key. It's a small database change that dramatically improves the user experience.

Fallback Flows for Legacy Users

I would be doing you a disservice if I claimed passkeys are a complete replacement for passwords today. There are still edge cases:

  • Users on older browsers without WebAuthn support
  • Users on devices without biometrics or hardware security keys
  • Enterprise users behind restrictive IT policies
  • Users who lose access to all their synced devices

The pragmatic approach I recommend is a hybrid model: support both passkeys and a traditional password fallback. Here's a strategy that works well in production:

  1. New users: Encourage passkey creation during onboarding. Make it the default, but offer "Use a password instead" as a secondary option.
  2. Existing users: Add a "Add a passkey" option in account settings. When users log in with a password, surface a prompt to upgrade to a passkey.
  3. Recovery: Implement account recovery via email magic links. This is more secure than security questions and works well as a backup.
  4. Enterprise: Offer TOTP as a secondary fallback for organizations that need to enforce specific compliance requirements.

Production Best Practices From Real Deployments

After deploying passkey authentication for dozens of clients, here are the hard-won lessons I can share:

Always set the correct RP ID. This is the most common production mistake I see. The RP ID must be a valid domain or a registrable suffix of the origin. If your app runs on app.example.com, your RP ID should be example.com, not app.example.com. Get this wrong and your passkeys won't work on subdomains.

Validate the origin carefully. The origin in your verification must exactly match the origin of your app in production. I've debugged countless "works in dev, broken in prod" issues that all traced back to origin mismatches.

Store the AAGUID and attestation data. In 2026, regulators in financial services and healthcare require you to know what type of authenticator was used. Store the AAGUID and attestation metadata for audit purposes.

Implement rate limiting on your challenge endpoints. Even though passkeys are inherently secure, you still need to rate limit your API endpoints to prevent DoS attacks against your challenge generation.

Log authentication events for security monitoring. When a user logs in from a new device, when a new passkey is added, when an account is recovered — log everything. Your SOC team will thank you.

Test cross-browser compatibility. Safari, Chrome, Firefox, and Edge all have slightly different WebAuthn behaviors. Test every flow on every major browser before shipping.

Looking Ahead: The Future of Authentication

Passkeys are not the end of the authentication story, but they are a massive leap forward. In 2026, I'm starting to see early experiments with verifiable credentials, decentralized identifiers, and zero-knowledge proofs. But for the next 3-5 years, passkeys will be the foundation of secure web authentication.

If you're building a new Next.js app in 2026, there is no good reason to start with passwords. If you're modernizing an existing app, you can roll out passkeys gradually without disrupting your users. Either way, the time to act is now.

Frequently Asked Questions

What happens if a user loses their device? With cross-device sync enabled, users can recover their passkeys on a new device by signing into their Apple ID, Google account, or password manager. For users without sync, you should provide account recovery via email or backup authentication methods.

Are passkeys more secure than passwords? Yes, significantly. Passkeys are phishing-resistant, cannot be reused across sites, and the private key never leaves the user's device. Even a complete database breach on your server reveals nothing an attacker can use.

Do all browsers support WebAuthn now? As of 2026, all major browsers (Chrome, Safari, Firefox, Edge) support WebAuthn and passkeys on both desktop and mobile. Coverage is over 95% of global users.

Can I use passkeys alongside existing password authentication? Absolutely. In fact, I recommend this hybrid approach during the transition period. Most users will gradually migrate to passkeys while you retain password support for edge cases.

Should I use a third-party service like Clerk or Auth0 instead? For larger teams that need to ship fast, yes. For teams that want full control and lower long-term costs, building it yourself with libraries like SimpleWebAuthn is very achievable in a sprint or two.

#Next.js#Web Development#Authentication#Security

How to do it

  1. 1

    Set up your Next.js project with required dependencies

    Create a new Next.js 15 project with TypeScript and install @simplewebauthn/server, @simplewebauthn/browser, and Prisma. Configure your database schema to store WebAuthn credentials including credentialID, publicKey, counter, deviceType, and transports for each user.

  2. 2

    Build the registration API endpoints

    Create two API routes: one that generates registration options with generateRegistrationOptions from @simplewebauthn/server, and another that verifies the attestation response and stores the public key in your database. Set rpID to your domain and configure authenticator selection to prefer resident keys and user verification.

  3. 3

    Build the login API endpoints

    Create login endpoints that generate authentication options for the user's registered credentials, then verify the authentication response. On successful verification, increment the counter in your database to prevent replay attacks and update the lastUsedAt timestamp for security monitoring.

  4. 4

    Implement the React client components

    Use the startRegistration and startAuthentication functions from @simplewebauthn/browser to build React components that handle the WebAuthn ceremony. Display clear UI prompts explaining the authentication method and provide fallback options for browsers or devices that don't support passkeys.

  5. 5

    Configure for production and deploy

    Set environment variables for RP_ID, ORIGIN, and database connections. Enable cross-device sync by using discoverable credentials with residentKey preferred. Implement rate limiting on challenge endpoints, log authentication events for security monitoring, and test thoroughly across all major browsers before launch.

Frequently asked questions

What happens if a user loses their device with their passkey?

With cross-device sync enabled through Apple iCloud Keychain, Google Password Manager, or a third-party password manager, users can recover their passkeys on a new device by simply signing into their account. For users without sync enabled, you should provide account recovery via email magic links or backup authentication methods as a fallback.

Are passkeys more secure than traditional passwords?

Yes, passkeys are significantly more secure than passwords. They are phishing-resistant by design (bound to the originating domain), cannot be reused across different sites, and the private key never leaves the user's device. Even if your server database is completely breached, attackers gain no useful credentials since only public keys are stored server-side.

Do all browsers support WebAuthn and passkeys in 2026?

Yes, as of 2026, all major browsers including Chrome, Safari, Firefox, and Edge support WebAuthn and passkeys on both desktop and mobile platforms. Global coverage now exceeds 95% of internet users, making passkeys a viable primary authentication method for most applications.

Can passkeys coexist with traditional password authentication?

Absolutely. I recommend a hybrid approach where passkeys are encouraged as the primary method, but password authentication remains available as a fallback. This is especially important during the transition period and for users on older devices or restrictive enterprise environments. You can gradually migrate users to passkeys over time.

Should I use a third-party service like Clerk, Auth0, or build my own passkey implementation?

For larger teams that need to ship quickly and have the budget, third-party services like Clerk and Auth0 are excellent choices. For teams that want full control, lower long-term costs, and deeper customization, building your own with libraries like SimpleWebAuthn is very achievable within a single sprint and gives you complete ownership of the authentication flow.

More articles

Related reading from the same areas — practical notes on shipping software.

View all articles

Liked the article?

Have a similar problem in your business? Let's talk about building the fix.

Start a project