Step-by-Step Guide to WebAuthn-Based 2FA Using SimpleWebAuthn Library
Introduction
WebAuthn is a modern authentication standard that enables passwordless and phishing-resistant login using biometrics (Face ID, fingerprints), security keys, or device PINs. It enhances security by eliminating reliance on traditional passwords, reducing the risk of credential theft and phishing attacks.
In this blog, I will guide you through integrating SimpleWebAuthn into your existing project with a step-by-step approach, making it easier to implement secure authentication.
How SimpleWebAuthn Works
WebAuthn uses public-key cryptography and a challenge-response mechanism for secure authentication. Instead of passwords, users authenticate with biometrics or security keys, ensuring only legitimate users can log in.
Registration (Credential Creation)
During registration, the server generates a challenge and sends it to the frontend. The user verifies their identity using biometrics, a security key, or a device PIN. The authenticator (device or security key) then generates a key pair consisting of a public key and a private key. The public key and signed challenge are sent back to the server, where they are securely stored as the user’s credential.
Authentication (Login)
When a user attempts to log in, the server generates a new challenge and sends it to the frontend. The user verifies their identity using their previously registered authentication method. The authenticator signs the challenge using the private key stored on the device and returns the response. The server then verifies the signature using the stored public key. If the signature is valid, the user is authenticated successfully.
This process ensures that credentials are bound to the user’s device and cannot be phished or stolen like traditional passwords, making authentication significantly more secure.Prerequisites
Before starting, ensure you have:
A Node.js backend (Express preferred)
A React frontend (or any other frontend framework)
Basic knowledge of authentication (JWT, sessions, etc.)
Step 1: Install SimpleWebAuthn
To get started, install the required dependencies in your project.
Backend Installation:
npm install @simplewebauthn/server
Frontend Installation:
npm install @simplewebauthn/browser
Step 2: Setting Up Backend API
Create a .env file inside the server folder and define your WebAuthn settings.
Deployment:
RP_ID=yourdomain.com # Do not include 'http://', only the domain/subdomain name (e.g. example.com)
ORIGIN=http://example.com
RP_NAME=random_name
Localhost:
RP_ID=localhost
ORIGIN=http://localhost:5173 # Your localhost frontend
RP_NAME=random_name
Now, in your index.ts (or server.ts) main file, import 'dotenv/config' to load environment variables:
import 'dotenv/config';
import express from "express;
const app=express();
app.use('/api/user,userRouter);
app.use('/api/passkey,passkeyRouter);
app.listen(3000,()=>console.log("Server listening on port 3000");
Create Models
In server/src/models, define the necessary data models.
1. User Model
import mongoose from "mongoose"
const userSchema = new mongoose.Schema<IUserDocument>({
username: { type: String, required: true },
email: { type: String, required: true, unique: true, match: [/^\S+@\S+\.\S+$/] },
password: { type: String, required: true },
twoFactorAuth: { type: boolean, default: false },
passkey: [{ type: mongoose.Schema.Types.ObjectId, ref: "Passkey" }],
}, { timestamps: true });
export const User = mongoose.model<IUserDocument>("User", userSchema);
2. Challenge Model
import mongoose from "mongoose"
const ChallengeSchema = new mongoose.Schema<IChallenge>(
{
userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
payload: { type: String, required: true },
createdAt: { type: Date, default: Date.now, expires: 300 }, // 5 minutes
},{ timestamps: true });
export const Challenge = mongoose.model<IChallenge>("Challenge",ChallengeSchema);
3. Passkey Model
import mongoose from "mongoose"
const PasskeySchema = new mongoose.Schema<IPasskey>({
userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
credentialID: { type: String, required: true, unique: true },
publicKey: { type: Buffer, required: true },
transports: { type: [String], enum: ["usb", "nfc", "ble", "internal", "hybrid"] },
counter: { type: Number, required: true, default: 0 },
});
export const Passkey = mongoose.model<IPasskey>("Passkey", PasskeySchema);
Create Controllers
In server/src/controllers, define define the necessary controllers.
1. Registration Passkey Endpoint
export const registerPasskey = async (req: AuthRequest, res: Response) => {
try {
const userId: string = req._id!;
const user: UserInfo = await User.findById(userId);
const userPasskeys: IPasskey[] = await Passkey.find({ userId });
//Return an error if the user or passkey is missing.
const options: PublicKeyCredentialCreationOptionsJSON =
await generateRegistrationOptions({
rpName: process.env.RP_NAME as string,
rpID: process.env.RP_ID as string,
userID: isoUint8Array.fromUTF8String(userId),
userName: user.username,
excludeCredentials: userPasskeys.map((passkey) => ({
id: passkey.credentialID,
})),
}); // Return an error if registration fails.
await Challenge.create({ userId, payload: options.challenge });
res.status(200).json({ options, success: true });
} catch (error) {}
};
2. Verification Passkey Endpoint
export const verifyPasskey = async (req: AuthRequest, res: Response) => {
try {
const { credential }: { credential: RegistrationResponseJSON } = req.body;
const userId: string = req._id!;
const user: UserInfo = await User.findById(userId);
const challenge: ChallengeInfo = await Challenge.findOne({ userId });
//Return an error if the user or challenge is missing.
const verificationResult: VerifiedRegistrationResponse =
await verifyRegistrationResponse({
response: credential,
expectedChallenge: challenge.payload,
expectedOrigin: process.env.ORIGIN!,
expectedRPID: process.env.RP_ID!,
}); // Return an error if verification fails.
const {id,publicKey,counter,transports} = verificationResult.registrationInfo.credential;
const passkey: IPasskey = await Passkey.create({
userId,
credentialID: id,
publicKey: Buffer.from(publicKey),
counter: counter,
transports: transports,
});
await Challenge.findOneAndDelete({ userId });
user.passkeys?.push(passkey.id);
await user.save();
res.status(200).json({ message: "Registration successful", success: true });
} catch (error) {}
};
3. Login Passkey Endpoint
export const loginWithPasskey = async (req: AuthRequest, res: Response) => {
try {
const userId: string = req._id!;
const user: UserInfo = await User.findById(userId);
const userPasskeys: IPasskey[] = await Passkey.find({ userId });
//Return an error if the user or passkey is missing.
const options: PublicKeyCredentialRequestOptionsJSON =
await generateAuthenticationOptions({
rpID: process.env.RP_ID!,
allowCredentials: userPasskeys.map((passkey) => ({
id: passkey.credentialID,
transports: passkey.transports,
})),
}); // Return an error if authentication fails.
await Challenge.create({ userId, payload: options.challenge });
res.status(200).json({ options, success: true });
} catch (error) {}
};
4. Login Verification Passkey Endpoint
export const verifyWithPasskey = async (req: AuthRequest, res: Response) => {
try {
const { credential }: { credential: AuthenticationResponseJSON } = req.body;
const userId = req._id!;
const user: UserInfo = await User.findById(userId);
const challenge: ChallengeInfo = await Challenge.findOne({ userId });
const passkey: PasskeyInfo = await Passkey.findOne({ userId, credentialID: credential.id });
////Return an error if the user, passkey or challenge is missing.
const verificationResult: VerifiedAuthenticationResponse =
await verifyAuthenticationResponse({
expectedChallenge: challenge.payload,
expectedOrigin: process.env.ORIGIN!,
expectedRPID: process.env.RP_ID!,
response: credential,
credential: {
id: passkey.credentialID,
publicKey: new Uint8Array(Buffer.from(passkey.publicKey)),
counter: passkey.counter,
transports: passkey.transports,
},
}); // Return an error if verification fails.
if (!verificationResult.verified) return res.status(400).json({ message: "Authentication failed", success: false });
// Prevent replay attacks using counter
if ( verificationResult.authenticationInfo?.newCounter > 0 && verificationResult.authenticationInfo.newCounter <= passkey.counter ){
return res.status(400).json({ message: "Counter replay detected", success: false });
}
// Update stored counter with the new counter from the authenticator
passkey.counter = verificationResult.authenticationInfo.newCounter;
await passkey.save();
await Challenge.findOneAndDelete({ userId });
const token: string = await user.generateToken();
res.status(200).cookie("token", token ).json({ message: "Login successful with passkey", success: true });
} catch (error) {}
};
Create Routes
In server/src/routes, define the required routes.
import express from "express";
import { isAuthenticated } from "../middlewares/auth";
import {loginWithPasskey,registerPasskey,verifyPasskey,verifyWithPasskey} from "../controllers/passkey.controller";
const router = express.Router();
router.route("/register").post(isAuthenticated, registerPasskey);
router.route("/verify").post(isAuthenticated, verifyPasskey);
router.route("/login-passkey").post(isAuthenticated, loginWithPasskey);
router.route("/verify-passkey").post(isAuthenticated, verifyWithPasskey);
export default router;
Step 3: Implementing WebAuthn on Frontend
1.Login With Passkey
const handleRegisterPasskey = async () => {
try {
const data = await fetch("/api/passkey/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const response = await data.json();
const registerPasskey = await startRegistration({ optionsJSON: response.options });
if (!registerPasskey) return toast.error("Invalid while register passkey");
const verifyPasskey = await fetch("/api/passkey/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential: registerPasskey }),
});
if (verifyPasskey) return toast.success("Passkey register successfully.");
} catch (error) {}
};
2.Verify With Passkey
const handleLoginWithPasskey = async ({ message, user }: { message: string; user: UserInfo;}) => {
const data = await fetch("/api/passkey/login-passkey", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const response = await data.json();
const loginPasskey = await startAuthentication({ optionsJSON: response.options });
if (!loginPasskey) return toast.error("Invalid while login passkey");
const verifyPasskeyResponse = await fetch("/api/passkey/verify-passkey", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential: loginPasskey }),
});
const verifyPasskey = await verifyPasskeyResponse.json();
if (verifyPasskey.success) { //change according to your code base
toast.success(message);
dispatch(setUser(user));
navigate("/");
}
};
Videos and Screenshots
https://drive.google.com/drive/folders/1VhzQiVkNvrmzy3Na49BtLJ0i7bKd-dym?usp=sharing
Conclusion
By following these steps, you have successfully integrated WebAuthn authentication using SimpleWebAuthn. This enhances security by enabling passwordless authentication, improving user experience, and reducing phishing risks.
For more details on SimpleWebAuthn, check the official documentation. If you face issues, explore community discussions or connect with WebAuthn developers for support.