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 "@/*"
Navigate into the project directory:
cd webauthn-react-next
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
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 };
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 });
}
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 });
}
}
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 });
}
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 });
}
}
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}
)}
);
}
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 (
);
}
Step 6: Test the Implementation
Start the Next.js development server:
npm run dev
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
Mapto 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_IDto your production domain (e.g.,your-app.com) andORIGINto 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)