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.
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.
npx create-next-app@latest webauthn-nextjs
cd webauthn-nextjs
after creating the project, let's start the application by runing the command below.
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.
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:
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 server, create a new database named webauthen_demo, and run the query below to create the database schema.
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, andemail. - 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.
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.
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
POSTfunction receives ausernamefrom the frontend and handles the registration setup request for WebAuthn. - The
generateRegistrationOptionsmethod from@simplewebauthn/servercreates 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.
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, andregistrationResponse(passkey credential response) from the frontend request body. - The
verifyRegistrationResponsemethod from@simplewebauthn/servervalidates 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:
'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.
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.
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.
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.
'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'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.
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.
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.
'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.
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.
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 and Next.js API routes, you can create a seamless, passwordless authentication experience that enhances both security and user experience.
Top comments (0)