Securing Your Next.js App with WebAuthn: A Passwordless Login Experience

Share

We’ll walk through the core functionalities, showcasing a secure and user-friendly login experience. If you’re also deploying this Next.js application inside a subdirectory of an existing site, check out our guide on how to run a Next.js site in a subdirectory for the configuration steps.

Understanding WebAuthn

WebAuthn, a W3C standard, empowers secure web authentication through public-key cryptography. It eliminates the need for passwords, relying instead on a combination of usernames and biometric verification fingerprint scan or security keys. This significantly strengthens login security and streamlines the user experience.

Implementing WebAuthn in Next.js

Our Next.js application will leverage WebAuthn to achieve passwordless login. Here’s a breakdown of the key steps involved:

1. Project Setup and Dependencies:

  • Create a new Next.js application using
    npx create-next-app my-webauthn-appcd my-webauthn-app
  • Install the required dependencies:
    npm install @simplewebauthn/browser @simplewebauthn/server

2. Registration Flow (/register route):

On the /register route, the user creates an account with a username. The application then prompts the user’s device (phone, computer) for a biometric scan or security key registration. WebAuthn generates a public/private key pair on the device. The public key is stored on the server, while the private key remains securely on the user’s device.

Register

 

Client side

Inside the ‘app’ folder create another folder name ‘register’ and inside it create a file named ‘page.js’. Inside that file write the following code.

"use client";
import { useState } from "react";
import { startRegistration } from "@simplewebauthn/browser";

export default function Register() {
const [username, setUsername] = useState(“”);

const handleRegister = async () => {
const optionsResponse = await fetch(“/api/register”, {
method: “POST”,
headers: {
“Content-Type”: “application/json”,
},
body: JSON.stringify({ username }),
});

const options = await optionsResponse.json();

const attestationResponse = await startRegistration(options.data);

await fetch(“/api/register”, {
method: “PUT”,
headers: {
“Content-Type”: “application/json”,
},
body: JSON.stringify({ username, attestationResponse }),
});
};

return (
<>
<h1>Register</h1>
<input
type=”text”
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder=”Username”
/>
<button onClick={handleRegister}>Register</button>
</>
);
}

Server side

Inside the ‘app’ folder create another folder name ‘api’ and inside it create another folder named ‘register’, then inside this folder create another file named ‘route.js’. Inside that file write the following code.

import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { NextApiRequest, NextApiResponse } from "next";
import { isoUint8Array } from "@simplewebauthn/server/helpers";

const rpName = “Testing Application”; // Change this to your application name
const rpID = “localhost”; // Change this to your domain
const origin = “http://localhost:3000/“; // Change this to your URL

let userDatabase = {}; // For now we can you static data registered, later a database can be used for this

export async function POST(req) {
const incomingData = await req.json();
const username = incomingData.username;
if (!username) {
return Response.json({ error: “Username is required” });
}

// Generate options for the user to start registration
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: isoUint8Array.fromUTF8String(username),
userName: username,
attestationType: “none”,
});

// Store the options in our ‘database’
userDatabase[username] = {
…userDatabase[username],
challenge: options.challenge,
};

return Response.json({
message: “Data received and decrypted”,
data: options,
});
}

export async function PUT(req) {
const incomingData = await req.json();
const { username, attestationResponse } = incomingData;
const expectedChallenge = userDatabase[username]?.challenge;

if (!username || !attestationResponse || !expectedChallenge) {
return Response.json({ error: “Invalid request” });
}

try {
const verification = await verifyRegistrationResponse({
response: attestationResponse,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});

if (verification.verified) {
// Save the credential in our ‘database’
userDatabase[username] = {
…userDatabase[username],
credential: verification.registrationInfo,
};
return Response.json({ success: true });
}

return Response.json({ error: “Verification failed” });
} catch (error) {
console.log(“error => “, error);
return Response.json({ error: error.message });
}
}

3. Login Flow (/login route):

On the /login route, the user enters their username. The application initiates a WebAuthn challenge, requesting user authentication via biometrics or the security key. The user’s device leverages the private key to sign the challenge, demonstrating possession of the valid credential. The signed response is sent back to the server.

Login

Client side

Inside the ‘app’ folder create another folder named ‘login’ and inside it create a file named ‘page.js’. Inside that file write the following code.

"use client";
import { useState } from "react";
import { startAuthentication } from "@simplewebauthn/browser";

export default function Login() {
const [username, setUsername] = useState(“”);

const handleLogin = async () => {
const optionsResponse = await fetch(“/api/login”, {
method: “POST”,
headers: {
“Content-Type”: “application/json”,
},
body: JSON.stringify({ username }),
});

const options = await optionsResponse.json();

const assertionResponse = await startAuthentication(options);

const loginResponse = await fetch(“/api/login”, {
method: “PUT”,
headers: {
“Content-Type”: “application/json”,
},
body: JSON.stringify({ username, assertionResponse }),
});

if (loginResponse) {
alert(“Login successful”);
} else {
alert(“Login failed”);
}
};

return (
<div>
<h1>Login</h1>
<input
type=”text”
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder=”Username”
/>
<button onClick={handleLogin}>Login</button>
</div>
);
}

Server side

Inside the ‘api’ folder create another folder named ‘login’, then inside this folder create another file named ‘route.js’. Inside that file write the following code.

import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";

const rpID = “localhost”; // Change this to your domain
const origin = “http://localhost:3000/“; // Change this to your URL

let userDatabase = {}; // For now we can you static data registered, later a database can be used for this

export async function POST(req) {
const incomingData = await req.json();
const username = incomingData.username;
const user = userDatabase[username];
if (!user || !user.credential) {
return Response.json({ error: “User not found” });
}

const options = await generateAuthenticationOptions({
rpID,
allowCredentials: [
{
id: user.credential.credentialID,
type: “public-key”,
transports: [“usb”, “ble”, “nfc”, “internal”],
},
],
});

// Store the challenge in our ‘database’
userDatabase[username] = {
…userDatabase[username],
challenge: options.challenge,
};

return Response.json(options);
}

export async function PUT(req) {
const incomingData = await req.json();
const { username, assertionResponse } = incomingData;
const user = userDatabase[username];
const expectedChallenge = user?.challenge;

if (!username || !assertionResponse || !expectedChallenge) {
return Response.json({ error: “Invalid request” });
}

try {
const verification = await verifyAuthenticationResponse({
response: assertionResponse,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: user.credential,
});

if (verification.verified) {
return Response.json({ success: true });
}
return Response.json({ error: “Verification failed” });
} catch (error) {
console.log(“error =>”, error);
return Response.json({ error: error.message });
}
}

4. Verification:

Run the following command to run the application.

npm run dev

Please visit localhost:3000/register in your browser and register yourself there, once done visit localhost:3000/login to login yourself on the same device, with the same username and the same biometric.

Upon receiving the signed response, the server verifies it with the public key associated with the user’s username. Successful verification confirms the user’s identity and grants access to the application. A success message is displayed to the user.

Benefits of a Passwordless Login

This WebAuthn integration offers several advantages:

  • Enhanced Security: Eliminating passwords reduces the risk of phishing attacks and data breaches.
  • Improved User Experience: Biometric authentication is faster and more convenient than passwords.
  • Platform Agnostic: WebAuthn functions across various devices and browsers, promoting wider accessibility.

Conclusion

By incorporating WebAuthn into your Next.js application, you provide a more secure and user-friendly login experience. This blog post has provided a foundational understanding of WebAuthn and its implementation within a Next.js application. Remember, this is a simplified overview, and further exploration of the WebAuthn API and security best practices is recommended for a robust implementation.

At LN Webworks, our team of seasoned experts is always ready to assist you with your digital needs. Contact us today to schedule your free consultation!

LN Webworks
The Author
LN Webworks

Your Drupal Solution Partner

Read More Blogs

Illustration comparing a custom CRM and an off-the-shelf CRM, showing a business professional choosing between a tailored CRM solution and a packaged CRM software. Other, SaaS Development

SaaS CRMs win on speed. Custom wins on fit and ownership. This isn’t a pitch for either one. Every early-stage SaaS company hits this decision within the first year, usually right after the founder realizes a spreadsheet isn’t a CRM.  The default answer is almost always a SaaS CRM: HubSpot, Salesforce, Zoho, sign up in […]

Illustration of a modernization readiness assessment highlighting business alignment, technology assessment, process optimization, data integration, people and skills, risk and compliance, and a roadmap for successful digital transformation Other

Most “audits” and “assessments” promise clarity but deliver a sales deck with language that lacks clarity. You book the call expecting evidence about your specific platform, and what shows up is a generic pitch for whichever package is easiest for the firm to sell.  That’s the actual reason process transparency matters here, not because a […]

LN Webworks illustration of the BAT-BMS Bluetooth exploit, app ban, and access-control review process Other

In late June and early July, videos started circulating on Instagram of people walking up to moving e-rickshaws, tapping a phone, and watching the vehicle lose power mid-ride.  It spread as a prank trend before anyone fully understood the mechanism. Drivers in Patna (India) reported it first, assuming mechanical failure, until investigators traced it to […]

LN Webworks Logo
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.