Skip to main content

Command Palette

Search for a command to run...

Step-by-Step Guide to WebAuthn-Based 2FA Using SimpleWebAuthn Library

Updated
7 min read

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.