DEV Community

ANKUSH CHOUDHARY JOHAL
ANKUSH CHOUDHARY JOHAL

Posted on • Originally published at johal.in

Guide: Implementing WebAuthn for React 19 Apps Using Next.js 17 and SimpleWebAuthn 7.0

Guide: Implementing WebAuthn for React 19 Apps Using Next.js 17 and SimpleWebAuthn 7.0

WebAuthn (Web Authentication API) is a standards-based API that enables strong, passwordless authentication using hardware security keys, biometrics (like TouchID, FaceID), or device-based authenticators. For React 19 apps built with Next.js 17, pairing WebAuthn with SimpleWebAuthn 7.0 — a lightweight library that abstracts away complex WebAuthn spec details — simplifies implementation drastically. This guide walks through every step, from project setup to production-ready authentication flows.

Prerequisites

  • Node.js 22+ (required for Next.js 17 and React 19)
  • Basic knowledge of React 19 components, hooks, and Next.js 17 App Router
  • A code editor and terminal
  • A supported authenticator (e.g., YubiKey, TouchID-enabled device, Windows Hello)

Step 1: Initialize the Next.js 17 Project

Start by creating a new Next.js 17 project with the App Router, React 19, and TypeScript (recommended for type safety with SimpleWebAuthn):

npx create-next-app@17 webauthn-react-next --typescript --app --no-tailwind --import-alias "@/*"
Enter fullscreen mode Exit fullscreen mode

Navigate into the project directory:

cd webauthn-react-next
Enter fullscreen mode Exit fullscreen mode

Verify React 19 and Next.js 17 are installed by checking package.json — you should see react: "^19.0.0" and next: "^17.0.0".

Step 2: Install SimpleWebAuthn 7.0

SimpleWebAuthn provides separate packages for client and server logic. Install both:

npm install @simplewebauthn/browser@7.0 @simplewebauthn/server@7.0
Enter fullscreen mode Exit fullscreen mode

The @simplewebauthn/browser package handles client-side WebAuthn calls (registration, authentication) in your React components. The @simplewebauthn/server package manages server-side challenge generation, verification, and credential storage in Next.js API routes.

Step 3: Configure Server-Side WebAuthn Logic (Next.js 17 API Routes)

Next.js 17 uses the App Router with Route Handlers for API endpoints. Create a lib/webauthn.ts file to store shared WebAuthn configuration:

// lib/webauthn.ts
import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
import type { AuthenticatorDevice } from '@simplewebauthn/server';

// Replace with your production origin (e.g., https://your-app.com)
const RP_ID = 'localhost';
const RP_NAME = 'WebAuthn React Next Demo';
const ORIGIN = 'http://localhost:3000';

// In-memory store for demo purposes (replace with a database in production)
export const userAuthenticators = new Map();

export const webauthnConfig = {
  rpId: RP_ID,
  rpName: RP_NAME,
  origin: ORIGIN,
};

export { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse };
Enter fullscreen mode Exit fullscreen mode

Next, create Route Handlers for registration and authentication. First, registration options:

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

export async function POST(request: Request) {
  const { userId, username } = await request.json();

  if (!userId || !username) {
    return NextResponse.json({ error: 'Missing userId or username' }, { status: 400 });
  }

  const options = await generateRegistrationOptions({
    rpName: webauthnConfig.rpName,
    rpID: webauthnConfig.rpId,
    userID: userId,
    userName: username,
    attestationType: 'none',
    // Exclude already registered authenticators for this user
    excludeCredentials: [],
  });

  // Store the challenge temporarily (use session/Redis in production)
  // For demo, we'll return it to the client (not secure for production!)
  return NextResponse.json({ options });
}
Enter fullscreen mode Exit fullscreen mode

Then, the registration verification endpoint:

// app/api/register/verify/route.ts
import { NextResponse } from 'next/server';
import { verifyRegistrationResponse } from '@/lib/webauthn';
import { webauthnConfig, userAuthenticators } from '@/lib/webauthn';

export async function POST(request: Request) {
  const { userId, response } = await request.json();

  try {
    const verification = await verifyRegistrationResponse({
      response,
      expectedChallenge: response.challenge, // Replace with stored challenge in production
      expectedOrigin: webauthnConfig.origin,
      expectedRPID: webauthnConfig.rpId,
    });

    if (verification.verified) {
      const { credential } = verification;
      // Store the credential for the user
      const existing = userAuthenticators.get(userId) || [];
      userAuthenticators.set(userId, [...existing, credential as any]);
      return NextResponse.json({ verified: true, credentialID: credential.id });
    }

    return NextResponse.json({ verified: false }, { status: 400 });
  } catch (error) {
    console.error('Registration verification failed:', error);
    return NextResponse.json({ error: 'Verification failed' }, { status: 500 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, authentication endpoints. First, generate authentication options:

// app/api/auth/options/route.ts
import { NextResponse } from 'next/server';
import { generateAuthenticationOptions } from '@/lib/webauthn';
import { webauthnConfig, userAuthenticators } from '@/lib/webauthn';

export async function POST(request: Request) {
  const { userId } = await request.json();

  if (!userId) {
    return NextResponse.json({ error: 'Missing userId' }, { status: 400 });
  }

  const userDevices = userAuthenticators.get(userId) || [];
  const options = await generateAuthenticationOptions({
    rpID: webauthnConfig.rpId,
    // Allow only authenticators registered to this user
    allowCredentials: userDevices.map(device => ({
      id: device.credentialID,
      type: 'public-key',
      transports: device.transports,
    })),
    userVerification: 'preferred',
  });

  return NextResponse.json({ options });
}
Enter fullscreen mode Exit fullscreen mode

Then, verify authentication response:

// app/api/auth/verify/route.ts
import { NextResponse } from 'next/server';
import { verifyAuthenticationResponse } from '@/lib/webauthn';
import { webauthnConfig, userAuthenticators } from '@/lib/webauthn';

export async function POST(request: Request) {
  const { userId, response } = await request.json();

  const userDevices = userAuthenticators.get(userId) || [];
  const credential = userDevices.find(device => device.credentialID === response.id);

  if (!credential) {
    return NextResponse.json({ error: 'Credential not found' }, { status: 404 });
  }

  try {
    const verification = await verifyAuthenticationResponse({
      response,
      expectedChallenge: response.challenge, // Replace with stored challenge in production
      expectedOrigin: webauthnConfig.origin,
      expectedRPID: webauthnConfig.rpId,
      authenticator: credential as any,
    });

    if (verification.verified) {
      // Update authenticator counter (for production use)
      credential.counter = verification.authenticationInfo.newCounter;
      return NextResponse.json({ verified: true });
    }

    return NextResponse.json({ verified: false }, { status: 400 });
  } catch (error) {
    console.error('Authentication verification failed:', error);
    return NextResponse.json({ error: 'Verification failed' }, { status: 500 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Implement Client-Side Logic with React 19

React 19's enhanced hooks and component model work seamlessly with SimpleWebAuthn's browser package. Create a components/WebAuthnManager.tsx component to handle registration and authentication:

// components/WebAuthnManager.tsx
'use client';

import { useState } from 'react';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/browser';

export default function WebAuthnManager({ userId, username }: { userId: string; username: string }) {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const [message, setMessage] = useState('');

  const handleRegister = async () => {
    setStatus('loading');
    setMessage('');
    try {
      // 1. Get registration options from server
      const optionsRes = await fetch('/api/register/options', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId, username }),
      });
      const { options } = await optionsRes.json();

      // 2. Start registration with authenticator
      const registrationResponse: RegistrationResponseJSON = await startRegistration({ options });

      // 3. Verify registration with server
      const verifyRes = await fetch('/api/register/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId, response: registrationResponse }),
      });
      const { verified } = await verifyRes.json();

      if (verified) {
        setStatus('success');
        setMessage('Registration successful! You can now authenticate.');
      } else {
        throw new Error('Registration verification failed');
      }
    } catch (error) {
      setStatus('error');
      setMessage(`Registration failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
    }
  };

  const handleAuthenticate = async () => {
    setStatus('loading');
    setMessage('');
    try {
      // 1. Get authentication options from server
      const optionsRes = await fetch('/api/auth/options', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId }),
      });
      const { options } = await optionsRes.json();

      // 2. Start authentication with authenticator
      const authResponse: AuthenticationResponseJSON = await startAuthentication({ options });

      // 3. Verify authentication with server
      const verifyRes = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId, response: authResponse }),
      });
      const { verified } = await verifyRes.json();

      if (verified) {
        setStatus('success');
        setMessage('Authentication successful! Welcome back.');
      } else {
        throw new Error('Authentication verification failed');
      }
    } catch (error) {
      setStatus('error');
      setMessage(`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
    }
  };

  return (

      WebAuthn Management
      User: {username} (ID: {userId})



          {status === 'loading' ? 'Processing...' : 'Register Authenticator'}


          {status === 'loading' ? 'Processing...' : 'Authenticate'}



      {message && (

          {message}

      )}

  );
}
Enter fullscreen mode Exit fullscreen mode

Note the 'use client' directive at the top — required for React 19 client components that use browser APIs and hooks like useState.

Step 5: Integrate into the Next.js 17 App

Update the root page (app/page.tsx) to use the WebAuthnManager component. For demo purposes, we'll generate a random userId and use a hardcoded username:

// app/page.tsx
import WebAuthnManager from '@/components/WebAuthnManager';

export default function Home() {
  // In production, get userId from your session/auth system
  const userId = typeof window !== 'undefined' ? localStorage.getItem('userId') || crypto.randomUUID() : '';
  if (typeof window !== 'undefined' && !localStorage.getItem('userId')) {
    localStorage.setItem('userId', userId);
  }
  const username = 'demo-user';

  return (



  );
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Test the Implementation

Start the Next.js development server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Navigate to http://localhost:3000 in a WebAuthn-supported browser (Chrome, Firefox, Safari, Edge all support WebAuthn). Click "Register Authenticator" — your browser will prompt you to use an authenticator (e.g., TouchID, YubiKey). Follow the prompts to complete registration. Once registered, click "Authenticate" to test the login flow.

Production Best Practices

  • Replace in-memory storage: The demo uses a Map to store credentials — use a database (PostgreSQL, MongoDB) in production, and encrypt stored credential data.
  • Secure challenge storage: Store challenges in a server-side session or Redis with a short TTL, not in the client.
  • Set proper RP_ID and origin: Update RP_ID to your production domain (e.g., your-app.com) and ORIGIN to your full production URL.
  • Add session management: After successful authentication, set a secure, HttpOnly cookie to maintain user sessions.
  • Handle edge cases: Add support for multiple authenticators per user, credential recovery, and fallback authentication methods.
  • Type safety: Use TypeScript types from SimpleWebAuthn to avoid runtime errors, as shown in the examples.

Conclusion

Implementing WebAuthn in React 19 apps with Next.js 17 and SimpleWebAuthn 7.0 eliminates the need for passwords, reducing phishing risk and improving user experience. The libraries handle the complex WebAuthn spec details, letting you focus on building your app's core functionality. With the steps above, you can have a production-ready passwordless authentication flow in hours.

Top comments (0)