# How to Integrate WebAuthn in Next.js Passwords have long been the weakest link in digital security because they're easy to forget, often reused, and highly susceptible to phishing and data breaches. As users and developers seek more secure and user-friendly alternatives, WebAuthn (Web Authentication API) has emerged as a powerful standard for modern, hardware-backed authentication in today’s applications. In this tutorial, you’ll learn how to integrate WebAuthn into a Next.js application, enabling users to register and log in using passkeys such as fingerprints, Face ID, or security keys directly from their devices. The GIF below shows how the application we’re building will work. ![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXdbhMxlV3S4t5DUh2aey02qi4P1frKb36iQoqPYEPv6DbFqDBxD0JkO7T392dZXESOB5O0MRMrgNHV8f1cOIwe6x1ZESGhuHbQ_pSDJjYCssHn3B1M0bzt7wsfWYOz5MolOga4dbQ?key=l48URqnspbvO4WslD-xAKw) ## Prerequisites To follow along with this tutorial, ensure you meet the following requirements: - Node.js - MySQL installed and running - Basic knowledge of Next.js - A device with Biometric (Face ID, fingerprint) enabled ## What is WebAuthn? WebAuthn (Web Authentication API) is a modern web standard and part of the FIDO2 project that enables secure, passwordless authentication using public key cryptography. Supported by all major browsers and platforms, it offers a strong alternative to traditional passwords. Unlike passwords, which can be stolen, reused, or phished, WebAuthn uses unique, device-bound credentials. Authentication occurs locally with a securely stored private key on the user’s device, while only the public key is stored on the server. WebAuthn allows users authenticate using: - Biometrics (e.g., Face ID, fingerprint scanners) - Hardware security keys (e.g., YubiKey, Titan Security Key) - Built-in device credentials (e.g., Windows Hello, macOS Touch ID) ## Create new next.js project To get started with the application, let’s create a new next.js project. To do that, on your terminal, navigate to the directory that you want to create the project and run the command below. ```bash npx create-next-app@latest webauthn-nextjs cd webauthn-nextjs ``` after creating the project, let's start the application by runing the command below. ```bash npm run develop ``` ## Install Webauthn Dependencies For the application to use passkeys like service fingerprint, face id etc, you need to install @simplewebauthn dependencies that allow javascript applications to use WebAuthn. To do that, run the command below to install both the @simplewebauthn/browser and @simplewebauthn/server for the frontend and backend respectively. ```bash npm install @simplewebauthn/browser @simplewebauthn/server ``` ## Install other necessary dependencies Let’s install the mysql2 dependency for the application to store and interact with a MySQL database, and base64url to properly encode and decode data in a URL-safe Base64 format, which is required for WebAuthn data handling: ```bash npm install mysql2 base64url ``` ## Setup the database The application needs to store the user information and passkey details in the database. To set up the database schema, log in to your [MySQL](https://www.mysql.com/) server, create a new database named `webauthen_demo`, and run the query below to create the database schema. ```sql CREATE TABLE users ( id BINARY(32) NOT NULL, username VARCHAR(255) NOT NULL UNIQUE, display_name VARCHAR(255) NOT NULL, fullname VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; CREATE TABLE challenges ( id INT(11) NOT NULL AUTO_INCREMENT, username VARCHAR(255) NOT NULL, challenge VARCHAR(255) NOT NULL, user_id BINARY(32) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(), PRIMARY KEY (id), KEY username (username) ) CREATE TABLE credentials ( id INT(11) NOT NULL AUTO_INCREMENT, user_id BINARY(32) NOT NULL, credential_id VARBINARY(255) NOT NULL UNIQUE, public_key TEXT NOT NULL, counter INT(11) NOT NULL DEFAULT 0, device_type ENUM('singleDevice', 'multiDevice') NOT NULL, backed_up TINYINT(1) NOT NULL DEFAULT 0, transports LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (JSON_VALID(transports)), PRIMARY KEY (id), KEY user_id (user_id), CONSTRAINT credentials_ibfk_1 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ) ``` From the sql code above, we have: - The users table schema stores user information such as `id`, `username`, `display_name`, `fullname`, and `email`. - The credentials table schema stores passkey credential details associated with each user, generated during the registration process. - The challenges table schema stores temporary challenge data used during the login process for passkey validation. Next, in the Next.js application, let’s establish a connection to the database. To do this, open the project in your preferred code editor. From the project root directory, navigate to the `src` folder, create a `src/lib/db.js` file, and add the following code to it. ```js import mysql from 'mysql2/promise'; export const db = await mysql.createPool({ host: 'localhost', user: 'root', password: '', database: 'webauthn_demo', }); ``` From the code above, replace `db_host`, `db_username`, and `db_password` placeholders with your corresponding MySQL database details. ## Create Registration Flow Let’s create a registration page that allows users to create an account using a passkey, such as a fingerprint, Face ID, or any other method supported by their device. **Creating the Passkey Registration Option API** First, let’s create the `generate-registration-options` endpoint that will generates a cryptographic challenge and configuration required for passkey (WebAuthn credential) registration and sends them to the frontend so the user’s device can create the passkey. To do this, create a new file named `src/app/api/generate-registration-options/route.js` and add the following code. ```js import { generateRegistrationOptions } from '@simplewebauthn/server'; import { NextResponse } from 'next/server'; import { randomBytes } from 'crypto'; import { db } from '@/lib/db'; export async function POST(req) { try { const { username } = await req.json(); if (!username || typeof username !== 'string') { return NextResponse.json({ error: 'Valid username is required' }, { status: 400 }); } const trimmed = username.trim(); const userId = randomBytes(32); const options = await generateRegistrationOptions({ rpName: 'Demo Next App', rpID: 'localhost', userID: userId, userName: trimmed, userDisplayName: trimmed, timeout: 60000, attestationType: 'none', authenticatorSelection: { userVerification: 'preferred', residentKey: 'preferred', }, supportedAlgorithmIDs: [-7, -257], }); await db.execute( 'INSERT INTO challenges (username, challenge, user_id) VALUES (?, ?, ?)', [trimmed, options.challenge, userId] ); await db.execute( 'DELETE FROM challenges WHERE created_at < (NOW() - INTERVAL 5 MINUTE)' ); return NextResponse.json(options); } catch (err) { console.error('Error generating registration options:', err); return NextResponse.json( { error: 'Failed to generate registration options' }, { status: 500 } ); } } ``` From the code above, we have: - The `POST` function receives a `username` from the frontend and handles the registration setup request for WebAuthn. - The `generateRegistrationOptions` method from `@simplewebauthn/server` creates WebAuthn-compatible registration options, including a cryptographic challenge, relying party information (`rpName`, `rpID`), user details, and security preferences. - The generated challenge is stored in the challenges table along with the username and userId. This data will be used later to verify the device’s response when the user completes the registration process. **Creating the Passkey Registration Verification API** We also need to create the `verify-registration` endpoint, which completes the second half of the WebAuthn registration process by validating the passkey response returned by the user's device and securely storing the credential in the database. To do this, create a `src/app/api/verify-registration/route.js` file and add the following code. ```js import { verifyRegistrationResponse } from '@simplewebauthn/server'; import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; export async function POST(req) { try { const { username, fullname, email, response: registrationResponse } = await req.json(); if (!username) { return NextResponse.json({ verified: false, error: 'Username is required' }, { status: 400 }); } const trimmed = username.trim(); const [challengeRows] = await db.execute( `SELECT * FROM challenges WHERE username = ? ORDER BY created_at DESC LIMIT 1`, [trimmed] ); if (challengeRows.length === 0) { return NextResponse.json({ verified: false, error: 'No challenge found' }, { status: 400 }); } const stored = challengeRows[0]; const verification = await verifyRegistrationResponse({ response: registrationResponse, expectedChallenge: stored.challenge, expectedOrigin: 'http://localhost:3000', expectedRPID: 'localhost', requireUserVerification: false, }); if (!verification.verified || !verification.registrationInfo) { return NextResponse.json( { verified: false, error: 'Registration verification failed' }, { status: 400 } ); } const { credentialDeviceType, credentialBackedUp, } = verification.registrationInfo; const { id: credentialID, publicKey: credentialPublicKey, counter, transports, } = verification.registrationInfo.credential; if (!credentialPublicKey) { return NextResponse.json( { verified: false, error: 'Missing credentialPublicKey' }, { status: 500 } ); } const [userRows] = await db.execute( 'SELECT id FROM users WHERE username = ? LIMIT 1', [trimmed] ); const userId = userRows.length ? userRows[0].id : stored.user_id; if (!userRows.length) { await db.execute( 'INSERT INTO users (id, username, display_name, fullname, email) VALUES (?, ?, ?, ?, ?)', [stored.user_id, trimmed, trimmed, fullname || trimmed, email || ''] ); } const credentialIDBuffer = Buffer.from(credentialID); await db.execute( `INSERT INTO credentials ( user_id, credential_id, public_key, counter, device_type, backed_up, transports ) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ userId, credentialIDBuffer, Buffer.from(credentialPublicKey).toString('base64'), counter, credentialDeviceType, credentialBackedUp, JSON.stringify(transports || ['internal', 'hybrid']), ] ); await db.execute('DELETE FROM challenges WHERE username = ?', [trimmed]); return NextResponse.json({ verified: true, message: 'Registration successful' }); } catch (err) { return NextResponse.json( { verified: false, error: 'Internal server error during verification' }, { status: 500 } ); } } ``` From the code above, we have: - The POST function receives the `username`, `fullname`, `email`, and `registrationResponse` (passkey credential response) from the frontend request body. - The `verifyRegistrationResponse` method from `@simplewebauthn/server` validates the credential. If the credential is valid, the user details and passkey credential are securely stored in the database. **Creating the Registration Interface** Now, let’s create the user registration interface and connect it to the `generate-registration-options` and `verify-registration` endpoints so users can register using a passkey. To do this, create a new file named `src/app/register/page.js` and add the following code: ```js 'use client'; import { useState } from 'react'; import Link from 'next/link'; import { startRegistration } from '@simplewebauthn/browser'; export default function RegisterPage() { const [username, setUsername] = useState(''); const [fullname, setFullname] = useState(''); const [email, setEmail] = useState(''); const [message, setMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); const handleRegister = async () => { if (!username.trim() || !fullname.trim() || !email.trim()) { setMessage('❌ Please fill out all fields'); return; } setIsLoading(true); setMessage(''); try { const payload = { username: username.trim(), fullname: fullname.trim(), email: email.trim(), }; const optionsRes = await fetch('/api/generate-registration-options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!optionsRes.ok) { const err = await optionsRes.json(); setMessage(`❌ ${err.error || 'Registration failed'}`); } const options = await optionsRes.json(); const attResp = await startRegistration(options); const verifyRes = await fetch('/api/verify-registration', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, response: attResp }), }); if (!verifyRes.ok) { const err = await verifyRes.json(); setMessage(`❌ ${err.error || 'Verification failed'}`); } const verification = await verifyRes.json(); if (verification.verified) { setMessage('✅ Registration successful!'); } else { setMessage(`❌ Registration failed: ${verification.error || 'Unknown error'}`); } } catch (err) { console.error('Registration error:', err); if (err.name === 'NotSupportedError') { setMessage('❌ WebAuthn not supported on this device/browser'); } else if (err.name === 'NotAllowedError') { setMessage('❌ Registration cancelled or not allowed'); } else if (err.name === 'AbortError') { setMessage('❌ Registration timed out'); } else { setMessage(`⚠️ Error: ${err.message}`); } } finally { setIsLoading(false); } }; return ( <div className="container mt-5"> <div className="row justify-content-center"> <div className="col-md-6"> <div className="card shadow-sm"> <div className="card-body"> <h2 className="card-title text-center mb-4">Register</h2> <div className="mb-3"> <input type="text" className="form-control" placeholder="Full Name" value={fullname} onChange={(e) => setFullname(e.target.value)} disabled={isLoading} /> </div> <div className="mb-3"> <input type="email" className="form-control" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={isLoading} /> </div> <div className="mb-4"> <input type="text" className="form-control" placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} disabled={isLoading} /> </div> <button onClick={handleRegister} disabled={isLoading || !username.trim()} className="btn btn-primary w-100 mb-3" > {isLoading ? 'Registering...' : 'Register'} </button> {message && ( <div className={`alert ${ message.includes('✅') ? 'alert-success' : 'alert-danger' } text-center`} role="alert" > {message} </div> )} <p className="text-center mt-4"> Already have an account?{' '} <Link href="/login" className="text-decoration-none"> Login here </Link> </p> </div> </div> </div> </div> </div> ); } ``` In the code above, we create a simple registration form that allows users to register using a passkey, such as fingerprint or Face ID, as shown in the screenshot below. ![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXdT27T_n1Fuf03_C2FsCCHYDBW7VyxEda75Iw-054BHNVjloS-wRpoLQ2LKsIsHZq5EdZdcUuYF-pqEvMyL2MC1KybHeqb69M0hMpC1MLuav-VyydWRkcK2wHr4LT1JZOy-65ClAg?key=l48URqnspbvO4WslD-xAKw) ## Create the Login Flow Let’s implement the login flow that allows users to log in using a passkey. To do this, we’ll create the necessary API endpoints and user interface as follow: **Creating the Login Passkey Option API** First, let’s create an endpoint that allows users to retrieve and validate their saved passkey. To do this, create a new file named `src/app/api/generate-authentication-options/route.js` and add the following code. ```js import { generateAuthenticationOptions } from '@simplewebauthn/server'; import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; export async function POST(req) { try { const { username } = await req.json(); if (!username) { return NextResponse.json({ error: 'Username is required' }, { status: 400 }); } const trimmed = username.trim(); const [userRows] = await db.execute( 'SELECT id FROM users WHERE username = ? LIMIT 1', [trimmed] ); if (userRows.length === 0) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } const userId = userRows[0].id; const [credRows] = await db.execute( `SELECT credential_id, transports FROM credentials WHERE user_id = ?`, [userId] ); if (credRows.length === 0) { return NextResponse.json({ error: 'No credentials found for user' }, { status: 404 }); } const options = await generateAuthenticationOptions({ timeout: 60000, rpID: 'localhost', userVerification: 'preferred', }); await db.execute( `INSERT INTO challenges (username, challenge, user_id) VALUES (?, ?, ?)`, [trimmed, options.challenge, userId] ); return NextResponse.json(options); } catch (err) { return NextResponse.json({ error: 'Failed to generate authentication options' }, { status: 500 }); } } ``` From the code above, we retrieve the username from the frontend, use the `generateAuthenticationOptions` method to create authentication options for passkey login, and store the challenge in the database for later verification. **Creating the Passkey Verification API** Now, let’s create a route to verify the login passkey challenge. To do this, create a `src/app/api/verify-authentication/route.js` file and add the following code. ```js import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; export async function POST(req) { try { const body = await req.json(); const { username } = body; const credential = body; if (!username || !credential.rawId) { return NextResponse.json( { verified: false, error: 'Missing username or rawId' }, { status: 400 } ); } const trimmed = username.trim(); const rawIdString = credential.rawId; let credRows; [credRows] = await db.execute( `SELECT * FROM credentials WHERE credential_id = ? LIMIT 1`, [Buffer.from(rawIdString, 'utf8')] ); if (!credRows || credRows.length === 0) { const rawIdBuffer = Buffer.from(rawIdString, 'base64url'); [credRows] = await db.execute( `SELECT * FROM credentials WHERE credential_id = ? LIMIT 1`, [rawIdBuffer] ); } if (!Array.isArray(credRows) || credRows.length === 0) { return NextResponse.json( { verified: false, error: 'Credential not found' }, { status: 400 } ); } const row = credRows[0]; if (!row || !row.public_key) { return NextResponse.json( { verified: false, error: 'Invalid credential entry' }, { status: 500 } ); } const [challengeRows] = await db.execute( `SELECT * FROM challenges WHERE username = ? ORDER BY created_at DESC LIMIT 1`, [trimmed] ); if (!challengeRows || challengeRows.length === 0) { return NextResponse.json( { verified: false, error: 'No challenge found' }, { status: 400 } ); } await db.execute('DELETE FROM challenges WHERE username = ?', [trimmed]); return NextResponse.json({ verified: "verified", message:'Login successful', }); } catch (err) { return NextResponse.json( { verified: false, error: err.message || 'Server error' }, { status: 500 } ); } } ``` From the code above, the username and passkey credential are retrieved from the frontend, the associated credential is fetched from the database and validated, and a success or failure response is returned. **Creating the Login Interface** Now, let’s create the login interface for the application. To do this, create a new file named `src/app/login/page.js` and add the following code. ```js 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { startAuthentication } from '@simplewebauthn/browser'; export default function LoginPage() { const [username, setUsername] = useState(''); const [status, setStatus] = useState(''); const router = useRouter(); const handleLogin = async () => { setStatus('🔐 Starting authentication...'); try { const optionsRes = await fetch('/api/generate-authentication-options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), }); if (!optionsRes.ok) { const error = await optionsRes.json(); setStatus(`❌${error.error}`); return; } const options = await optionsRes.json(); const authResp = await startAuthentication(options); const verifyRes = await fetch('/api/verify-authentication', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, ...authResp, }), }); const verifyJSON = await verifyRes.json(); if (verifyJSON.verified) { setStatus('✅ Login successful!'); localStorage.setItem('Username', username); router.push('/profile'); } else { setStatus(`❌ Login failed: ${verifyJSON.error || 'Verification failed'}`); } } catch (err) { setStatus('⚠️ Error during login'); } }; return ( <div className="container mt-5"> <div className="row justify-content-center"> <div className="col-md-6"> <div className="card shadow-sm"> <div className="card-body"> <h1 className="card-title text-center mb-4">Login with Passkey</h1> <div className="mb-3"> <input type="text" className="form-control" placeholder="Enter username" value={username} onChange={(e) => setUsername(e.target.value)} /> </div> <button onClick={handleLogin} className="btn btn-primary w-100 mb-3" > Login </button> {status && ( <div className={`alert text-center ${ status.includes('✅') ? 'alert-success' : status.includes('❌') ? 'alert-danger' : 'alert-info' }`} role="alert" > {status} </div> )} <p className="text-center mt-4"> Don&apos;t have an account?{' '} <Link href="/register" className="text-decoration-none"> Register here </Link> </p> </div> </div> </div> </div> </div> ); } ``` In the code above, we created a login form with a username input field and used the `handleLogin()` function to send a request to the `/api/generate-authentication-options` endpoint, which returns the passkey options. These options are passed to `startAuthentication()`, prompting the user to authenticate with their passkey. The result is then sent to `src/api/verify-authentication` for validation before the user is redirected to their profile. ![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXep4zdQrmjjQsNJuczU2fQ5rtfQycnM18Sn4Ay-lN7drnpFmQ_LO_qEf_umsylzb9ciHuRT7VyZI-cczILqWHpOopVv6pAbAnAJRVjPmCAnBUSXDg5pNka6CNSShH8vsNd6BTLF8g?key=l48URqnspbvO4WslD-xAKw) ## Creating the User Profile API Next, let’s create an endpoint that fetches user profile details from the database and returns them to the frontend. To do this, create a new file named `src/app/api/user-profile/route.js` and add the following code. ```js import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; export async function POST(req) { try { const body = await req.json(); const { username } = body; if (!username || typeof username !== 'string') { return NextResponse.json( { error: 'Username is required' }, { status: 400 } ); } const [rows] = await db.execute( 'SELECT username, email FROM users WHERE username = ? LIMIT 1', [username.trim()] ); if (!rows || rows.length === 0) { return NextResponse.json( { error: 'User not found' }, { status: 404 } ); } return NextResponse.json(rows[0]); } catch (err) { return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); } } ``` ## Creating the Profile Interface Finally, let’s create a profile page that displays the logged-in user’s details. To do this, create a new file named `src/app/profile/page.js` and add the following code. ```js 'use client'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; export default function ProfilePage() { const [username, setUsername] = useState(''); const [profile, setProfile] = useState(null); const [error, setError] = useState(''); const router = useRouter(); useEffect(() => { const storedUsername = localStorage.getItem('Username'); if (!storedUsername) { router.push('/login'); return; } setUsername(storedUsername); const fetchProfile = async () => { try { const res = await fetch('/api/user-profile', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: storedUsername }), }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || 'Failed to fetch profile'); } const data = await res.json(); setProfile(data); } catch (err) { setError(err.message); } }; fetchProfile(); }, [router]); const handleLogout = () => { localStorage.removeItem('Username'); router.push('/login'); }; return ( <div className="container mt-5"> <div className="row justify-content-center"> <div className="col-md-6"> <div className="card shadow"> <div className="card-body"> <h3 className="card-title text-center mb-4">👤 Profile</h3> {error ? ( <> <div className="alert alert-danger text-center" role="alert"> {error} </div> <button className="btn btn-danger w-100" onClick={handleLogout} > Logout </button> </> ) : !profile ? ( <p className="text-center">Loading...</p> ) : ( <> <ul className="list-group list-group-flush mb-4"> <li className="list-group-item"> <strong>Username:</strong> {profile.username} </li> <li className="list-group-item"> <strong>Email:</strong> {profile.email} </li> <li className="list-group-item"> <strong>Joined:</strong> popoola </li> </ul> <button className="btn btn-danger w-100" onClick={handleLogout} > Logout </button> </> )} </div> </div> </div> </div> </div> ); } ``` The code above retrieves the logged-in username from local storage and uses it to fetch user details from the `src/api/user-profile` endpoint, then displays the data in the user profile interface. The screenshot below shows the user profile page. ![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXfpDn_Z-JHmIX6qY6oetbCEuu2cbnEyUksEa8VQCpMQRWJxShRb1qo1m0shd2nNdZ19PO9QuLG53xMwhak7AwM4iDCwpaGltok3O3N9jeBleTdygqh8XeK5KgkIP8ENboAFgODKtg?key=l48URqnspbvO4WslD-xAKw) ## Testing The Application To test the application, open `http://localhost:3000/register` in your browser and create an account. The application should behave as shown in the GIF below. ![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXdbhMxlV3S4t5DUh2aey02qi4P1frKb36iQoqPYEPv6DbFqDBxD0JkO7T392dZXESOB5O0MRMrgNHV8f1cOIwe6x1ZESGhuHbQ_pSDJjYCssHn3B1M0bzt7wsfWYOz5MolOga4dbQ?key=l48URqnspbvO4WslD-xAKw) ## Conclusion Integrating WebAuthn into a Next.js application offers a secure, modern way for users to authenticate using biometrics or hardware keys, eliminating the need for traditional passwords. In this tutorial, we implemented the full flow, from user registration and passkey creation to login and credential verification. By using [@simplewebauthn](https://simplewebauthn.dev/) and Next.js API routes, you can create a seamless, passwordless authentication experience that enhances both security and user experience.