Implementation of Web Biometric Authentication on React + Node js (SimpleWebAuthn).

04 December 2023




In this article we will implement Biometric Authentication using SimpleWebAuthn on React App & Express with typescript. You will be guided to implement it in localhost. For the developing & testing authentication on localhost you’ll need to run localhost with https://localhost otherwise it will not work. For the storage we will be using localhost as I think easy for the demonstration.


1. Preparing project for the frontend & backend

  • Create a react app of your choice, we are using Vite.js for this and can be easily manipulated for other react based frameworks.
  • Create a simple server with express for the backend.


2. Installing Dependencies

  • Frontend - In your package.json file add these dependencies.
    • @simplewebauthn/browser,
    • Vite-plugin-mkcert - Required only to add ssl on localhost (change according to your framework)
    • axios, - To fetch api data. (optional)

    OR


    Run this command to add all
    npm install @simplewebauthn/browser axios vite-plugin-mkcert 
  • Backend - In your package.json file add these dependencies.
    • @simplewebauthn/server
    • cors
    • node-localstorage : You can use db of your choice to store & get data. (optional)
    • nodemon : To update change in real time. (optional)

    OR


    Run this command to add all
    npm install @simplewebauthn/server cors node-localstorage nodemon

Note: You may need to add types manually if using typescript.


3. Create frontend

  • App.tsx - Simple frontend with two buttons to call registration & authentication function.
      
        import { useState } from "react";
        import { deviceRegistration } from "./utils/device-registration";
        import { verification } from "./utils/device-verification";
        import axios from "axios";
        
        function App() {
            const [error, setError] = useState("");
            const [success, setSuccess] = useState("");
        
            return (
                <div className="flex items-center justify-center h-screen">
                    <div className="flex flex-col items-center w-full max-w-sm gap-4 p-8 bg-gray-100">
                    <div>Biometric Registration</div>
            
                    <div
                        onClick={async () => {
                        await deviceRegistration(setError, setSuccess);
                        }}
                        className="w-full py-2 text-center bg-blue-200 rounded-lg cursor-pointer select-none"
                    >
                        Register
                    </div>
                    {/* same button as signup */}
                    <div
                        onClick={async () => {
                        await verification(setError, setSuccess);
                        }}
                        className="w-full py-2 text-center bg-green-200 rounded-lg cursor-pointer select-none"
                    >
                        Verify
                    </div>
                    <div className="text-xs text-center text-red-500">{error}</div>
                    <div className="text-xs text-center text-green-500">{success}</div>
                    </div>
                </div>
                );
        }
        
        export default App;
  • In this we call our backend to generate options & these options then will be provided to the startRegistration function from @simplewebauthn/browser package. This will prompt to authorize the available service (Face ID, Touch ID & Windows hello etc). Get full list of supported methods -
    https://simplewebauthn.dev/docs/packages/server#1a-supported-attestation-formats.
      
    // device-registration.tsx 
        import {
            browserSupportsWebAuthn,
            startRegistration,
        } from "@simplewebauthn/browser";
        import axios from "axios";
        
        export async function deviceRegistration(
            setError: Function,
            setSuccess: Function
        ) {
        
        // browserSupportsWebAuthn with check if your browser supports the WebAuthn API
        
            if (browserSupportsWebAuthn()) {
                try {
                    let data = await axios.get(`https://localhost:3002/registration-options `);
                            const opts = data.data;
                    
                            let attResp;
                            try {
                            attResp = await startRegistration(opts);
                            } catch (error: any) {
                            setError(error.message ?? "Something Went Wrong");
                            setSuccess("");
                            }
                    
                            let verificationResp = await axios.post(
                            `https://localhost:3002/registration-confirmation `,
                            {
                                attResp,
                            }
        
                    This will be used if you are using session based authentication
                
                        // {
                        //   withCredentials: true,
                        // }
                        );
                
                        if (verificationResp && verificationResp.data.verified) {
                        setSuccess("Device Registered Successfully");
                        setError("");
                        } else {
                        setError("Something Went Wrong2");
                        setSuccess("");
                        }
                    }
    
                 catch (error: any) {
                        setError(error.message ?? "Something Went Wrong");
                        setSuccess("");
                    }}
    
                 else {
                    setError("WebAuthn is not supported in this browser");
                    }
                } 
  • Similarly we will create another function for authentication which will work the same way as above.
      
        // Device-verification.ts
            import {
            browserSupportsWebAuthn,
            startAuthentication,
            } from "@simplewebauthn/browser";
            import axios from "axios";
            
            export async function verification(
                setError: Function,
                setSuccess: Function
            ): Promise {
                // check if browser supports the WebAuthn API
                if (browserSupportsWebAuthn()) {
                    try {
                        let data = await axios.get(`https://localhost:3002/authentication-options`
                                    );
                                    const opts = data.data;
                            
                                    let attResp;
                                    try {
                                    attResp = await startAuthentication(opts);
                                    } catch (error: any) {
                                    setError(error.message ?? "Something Went Wrong");
                                    setSuccess("");
                                    }
                            
                                    let verificationResp = await axios.post(
                                    `https://localhost:3002/authentication-confirmation`,
                                    {
                                        attResp,
                                    }
                                    // this will be used if you are using session based authentication
                                    // {
                                    //   withCredentials: true,
                                    // }
                                    );
                            
                                    if (verificationResp && verificationResp.data.verified) {
                                    setSuccess("Device Authentication Successful");
                                    setError("");
                                    } else {
                                    setError("Something Went Wrong");
                                    setSuccess("");
                                    }
                                } 
                    
                    catch (error: any) {
                                    setError(error.message ?? "Something Went Wrong");
                                    setSuccess("");
                                } } 
                    else {setError("WebAuthn is not supported in this browser");}
            }


4. Create Backend

  • app.ts
      
        import express, { Request, Response } from "express";
        import { registerOptions } from "./auth/registration-options";
        import { registrationConfirmation } from "./auth/registration-confirmation";
        import Cors from "cors";
        import fs from "fs";
        import https from "https";
        // import session from "express-session";
        import { authenticationConfirmation } from "./auth/authentication-confirmation";
        import { authOptions } from "./auth/authentication-options";
    
        const app = express();
    
        app.use(
        Cors({
            origin: "https://localhost:5173", // replace with your frontend url
            // credentials: true, // enable set cookie if using session based authentication
            methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
        })
        );
    
        // uncomment this if you are using session based authentication
        // app.use(
        //   session({
        //     secret: "123456789",
        //     resave: false,
        //     saveUninitialized: true,
        //     cookie: { secure: true },
        //   })
        // );
    

    This step is required to use HTTPS in localhost only in production, you will need to get a certificate from a trusted Certificate provider to generate a self-signed certificate, run the following command in the terminal

      
        openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
        

    You will have two files generated on your folder key.pem & cert.pem. Put these files to “secret” folder in the server or you can map the path to these files.

    Read the certificate and private key from the filesystem.

      
        const privateKey = fs.readFileSync("secrets/key.pem", "utf8");
        const certificate = fs.readFileSync("secrets/cert.pem", "utf8");
        
        // create a credentials object
        const credentials = { key: privateKey, cert: certificate };
        
        // create the HTTPS server
        const httpsServer = https.createServer(credentials, app);
        
        // add json middleware for parsing json body in requests
        app.use(express.json());
        
        // add a simple route
        app.get("/", (req: Request, res: Response) =>
          res.status(200).send({
            data: `server says : get request on time : 1701771869054`,
          })
        );
        
        // add the routes for registration and verification
        app.get("/registration-options", registerOptions);
        app.post("/registration-confirmation", registrationConfirmation);
        app.get("/authentication-options", authOptions);
        app.post("/authentication-confirmation", authenticationConfirmation);
        
        // listen on the desired port
        httpsServer.listen(3002, () => {
          console.log("HTTPS Server running on port 3002");
        });
    
    
  • registration-options.ts - In this backend generates options & frontend use these options for device registration.
      
        import {
            GenerateRegistrationOptionsOpts,
            generateRegistrationOptions,
          } from "@simplewebauthn/server";
          import { Request, Response } from "express";
          import { LocalStorage } from "node-localstorage";
          import { Device, User } from "../utils/types";
          
          export async function registerOptions(req: Request, res: Response) {
            const localStorage = new LocalStorage("./scratch");
            console.log("registrationOptions");
            // get user from local storage
          
            const u = localStorage.getItem("user") ?? undefined;
            let user: User;
            if (!!u) {
              user = JSON.parse(u);

    We need to convert the regular arrays to Uint8Arrays Because regular arrays are easier to store in local storage But the WebAuthn API requires Uint8Arrays You can skip this step if you are storing the data as Uint8Arrays in your database

      
        const dd: Device[] = user.devices.map((device: Device) => {
            console.log("device", device.credentialPublicKey);
        
            const uint8Array32 = new Uint8Array(32);
            const uint8Array272 = new Uint8Array(272);
            for (let i = 0; i < 32; i++) {
                uint8Array32[i] = device.credentialID[i] || 0;
            }
            for (let i = 0; i < 272; i++) {
                uint8Array272[i] = device.credentialPublicKey[i] || 0;
            }
            return {
                ...device,
                credentialID: uint8Array32,
                credentialPublicKey: uint8Array272,
            };
            });
            user = {
            ...user,
            devices: dd,
            };
        } else {
            user = {
            id: "internalUserId",
            username: "user@localhost",
            devices: [],
            };
        }
        let options;
        
        const rpId = "localhost";
        if (!rpId) {
            throw new Error("No RP_ID configured");
        }
        try {
            const devices = user.devices;
            // add types from @simplewebauthn/server
            const opts: GenerateRegistrationOptionsOpts = {
                rpID: rpId,
                rpName: "SimpleWebAuthn Example",
                userID: user.id,
                userName: user.username,
                timeout: 60000,
                attestationType: "none",
                

    Passing in a user \'s list of already-registered authenticator IDs here prevents users from registering the same device multiple times.The authenticator will simply throw an error in the browser if it \'s asked to perform registration when one of these ID \'s already resides on it.

     
                excludeCredentials: devices.map((dev) => {
                    let id = new Uint8Array(32);
                    for (let i = 0; i < 32; i++) {
                        id[i] = dev.credentialID[i] || 0;
                    }
                    return {
                        id: id,
                        type: "public-key",
                        transports: dev.transports,
                    };
                }),
                authenticatorSelection: {
                    residentKey: "discouraged",
                },
                supportedAlgorithmIDs: [-7, -257],
            };
            console.log("opts --- ", opts.excludeCredentials);
    
            options = await generateRegistrationOptions(opts);
             // req.session.currentChallenge = options.challenge;
                localStorage.setItem("registrationOptions", JSON.stringify(options));
            // save user to local storage
            localStorage.setItem("user", JSON.stringify(user));
        } 
        
        catch (error: any) {
                console.log(error ?? "Unknown error");
                return res.status(500).json({
                 error: error.message || "Unknown error",
        });
        }
    
        return res.json(options);
        }
  • Now these options will be used on frontend to register the device & response will be received on ‘/registration-confirmation’;
    registration-confirmation.ts - In this Simplewebauthn will confirm user’s action & authenticate user device based on challenge generated on previous call (​​"/registration-options").
    npm install @simplewebauthn/browser axios vite-plugin-mkcert
      
        import { isoUint8Array } from "@simplewebauthn/server/helpers";
        import { verifyRegistrationResponse } from "@simplewebauthn/server";
        import { LocalStorage } from "node-localstorage";
        import { Request, Response } from "express";
        import { User } from "../utils/types";
    
        export async function registrationConfirmation(req: Request, res: Response) {
        const localStorage = new LocalStorage("./scratch");
        let user: User | null = JSON.parse(localStorage.getItem("user") || "null");
        if (!user) {
        user = {
        id: "internalUserId",
        username: "user@localhost",
        devices: [],
        };
        } else {
        // We need to convert the regular arrays to Uint8Arrays
        // because regular arrays are easier to store in local storage
        // but the WebAuthn API requires Uint8Arrays
        // You can skip this step if you are storing the data as Uint8Arrays in your database
        const devices = user.devices.map((device) => {
        const uint8Array32 = new Uint8Array(32);
        const uint8Array272 = new Uint8Array(272);
        for (let i = 0; i < 32; i++) {
            uint8Array32[i] = device.credentialID[i] || 0;
        }
        for (let i = 0; i < 272; i++) {
            uint8Array272[i] = device.credentialPublicKey[i] || 0;
        }
        return {
            ...device,
            credentialID: uint8Array32,
            credentialPublicKey: uint8Array272,
        };
        });
        user = {
            ...user,
            devices,
        };
        }
        const body = req.body.attResp;
        const options = JSON.parse(localStorage.getItem("registrationOptions") || "{}"); // const
        challenge = req.session.currentChallenge;
        const expectedChallenge = options.challenge;
        let verification;
        try {
            const opts = {
                response: body,
                expectedChallenge: `${expectedChallenge}`,
                expectedOrigin: "https://localhost:5173" , 
                expectedRPID: "localhost" , 
                requireUserVerification: true, 
            };
                verification=await verifyRegistrationResponse(opts); } catch (error: any) { return
                res.status(500).json({ error: error.message || "Unknown error" , }); } const { verified,
                registrationInfo }=verification; let existingDevice; let newDevice; if (verified && registrationInfo) {
            try {
                const {
                    credentialPublicKey,
                    credentialID,
                    counter
                } = registrationInfo; // this prevents
                registering the same device twice existingDevice = user?.devices?.find((device) => {
                    return isoUint8Array.areEqual(device.credentialID, credentialID);
                });
            
                if (!existingDevice) {
                    newDevice = {
                        credentialPublicKey,
                        credentialID,
                        counter,
                        transports: body?.response?.transports ?? [],
                    };
                }
            } catch (error: any) {
                return res.status(500).json({
                    error: error.message || "Unknown error",
                });
            }
            }
    
            // save device to user devices in local storage
            if (newDevice) {
            user.devices.push(newDevice);
            localStorage.setItem("user", JSON.stringify(user));
            }
            return res.json({ verified, registrationInfo, newDevice });
            }  
  • After successful confirmation the device will be added to the user & can be used to authenticate the user. authentication-options.ts - Generate options for authentication.
      
        import {
        GenerateAuthenticationOptionsOpts,
        generateAuthenticationOptions,
        } from "@simplewebauthn/server";
        import { Request, Response } from "express";
        import { LocalStorage } from "node-localstorage";
        import { User } from "../utils/types";
        
        export async function authOptions(req: Request, res: Response) {
        const localStorage = new LocalStorage("./scratch");
        const rpId = "localhost";
        let user: User | null = JSON.parse(localStorage.getItem("user") || "null");
        
        if (!user?.devices?.length) {
            return res.status(500).json({
            error: "No user found",
            });
        } else {
            // We need to convert the regular arrays to Uint8Arrays
            // because regular arrays are easier to store in local storage
            // but the WebAuthn API requires Uint8Arrays
            // You can skip this step if you are storing the data as Uint8Arrays in your database
        
            const devices = user.devices.map((device) => {
            console.log("device", device.credentialPublicKey);
        
            const uint8Array32 = new Uint8Array(32);
            const uint8Array272 = new Uint8Array(272);
            for (let i = 0; i < 32; i++) {
                uint8Array32[i] = device.credentialID[i] || 0;
            }
            for (let i = 0; i < 272; i++) {
                uint8Array272[i] = device.credentialPublicKey[i] || 0;
            }
            return {
                ...device,
                credentialID: uint8Array32,
                credentialPublicKey: uint8Array272,
            };
            });
            user = {
            ...user,
            devices,
            };
        }
        
        const opts: GenerateAuthenticationOptionsOpts = {
            timeout: 60000,
            allowCredentials: user?.devices?.length
            ? user?.devices?.map((dev) => {
                return {
                    id: dev.credentialID,
                    type: "public-key",
                    transports: dev.transports,
                };
                })
            : undefined,
            userVerification: "required",
            rpID: rpId,
        };
        
        const options = await generateAuthenticationOptions(opts);
        // session.currentChallenge = data.challenge;
        
        localStorage.setItem("authOptions", JSON.stringify(options));
        
        return res.json(options);
        }
  • These options then used in frontend to verify & will be confirmed in next api call authentication-confirmation.ts
      
        import { isoBase64URL, isoUint8Array } from "@simplewebauthn/server/helpers";
        import {
            VerifyAuthenticationResponseOpts,
            verifyAuthenticationResponse,
        } from "@simplewebauthn/server";
        import { LocalStorage } from "node-localstorage";
        import { Request, Response } from "express";
        import { Options, User } from "../utils/types";
        
        export async function authenticationConfirmation(req: Request, res: Response) {
            const localStorage = new LocalStorage("./scratch");
            const body = req.body.attResp;
            console.log("body", body);
        
            let user: User | null = JSON.parse(localStorage.getItem("user") || "null");
            if (!user) {
            user = {
                id: "internalUserId",
                username: "user@localhost",
                devices: [],
            };
            } else {
            // We need to convert the regular arrays to Uint8Arrays
            // because regular arrays are easier to store in local storage
            // but the WebAuthn API requires Uint8Arrays
            // You can skip this step if you are storing the data as Uint8Arrays in your database
        
            const devices = user.devices.map((device) => {
                console.log("device", device.credentialPublicKey);
        
                const uint8Array32 = new Uint8Array(32);
                const uint8Array272 = new Uint8Array(272);
                for (let i = 0; i < 32; i++) {
                uint8Array32[i] = device.credentialID[i] || 0;
                }
                for (let i = 0; i < 272; i++) {
                uint8Array272[i] = device.credentialPublicKey[i] || 0;
                }
                return {
                ...device,
                credentialID: uint8Array32,
                credentialPublicKey: uint8Array272,
                };
            });
            user = {
                ...user,
                devices,
            };
            }
        
            const rpId = "localhost";
            const expectedOrigin = "https://localhost:5173";
        
            //   const expectedChallenge = req.session.currentChallenge;
        
            const storedOptions = localStorage.getItem("authOptions");
            let expectedChallenge = "";
            if (!storedOptions) {
            return res.status(400).send({ error: "No registration options found" });
            } else {
            const registrationOptions = JSON.parse(storedOptions) as Options;
            expectedChallenge = registrationOptions.challenge;
            }
            let dbAuthenticator: any;
            const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId);
            // "Query the DB" here for an authenticator matching `credentialID `
            const devices = user.devices;
            for (const dev of devices) {
                if (isoUint8Array.areEqual(dev.credentialID, bodyCredIDBuffer)) {
                    dbAuthenticator = dev;
                    break;
                }
            }
            
            if (!dbAuthenticator) {
                return res.status(400).send({
                    error: "No authenticator found"
                });
            }
            
            let verification;
            
            try {
                const opts: VerifyAuthenticationResponseOpts = {
                        response: body,
                        expectedChallenge: `${expectedChallenge}`,
                        expectedOrigin: expectedOrigin,
                        expectedRPID: rpId,
                        authenticator: dbAuthenticator,
                        requireUserVerification: true,
                    };
                    verification = await verifyAuthenticationResponse(opts);
                    } catch (error: any) {
                    console.log("error ", verification ?? error ?? "Unknown error");
                    return res.status(500).json({
                        error: error.message || "Unknown error",
                    });
                    }
                
                    const { verified, authenticationInfo } = verification;
                    if (verified) {
                    // Update the authenticator\'s counter in the DB to the newest count in the authentication
                    dbAuthenticator.counter = authenticationInfo.newCounter;
                    }
                    return res.send({ verified });
        }