DEV Community

Andrii
Andrii

Posted on

Webauthn authentication with React and Nest.js

Overview

WebAuthn is a relatively new way of authentication for websites that offers better security than traditional authentication methods like password-based authentication.

Passwords have many weaknesses:

  • They can be guessable (birth date, dog name, etc.)
  • Most people use weak passwords, which are vulnerable to dictionary attacks
  • It’s possible to get them via phishing, darknet, or even in some cases if passwords are not hashed in the database as raw text.
  • And many more

When using WebAuthn, all of the enumerated problems above are gone. Hackers can’t:

  • Guess the password, because there’s no password
  • Use phishing because each passkey is connected strictly to a single website
  • Use database leaks for authorization purposes

This level of security exists because of the WebAuthn standard design. Shortly, your device creates a passkey, which is stored on your device, and you confirm creating that passkey for the website. The server verifies user identity, and here it is, the passkey created, and the user can be authenticated. Next time the user needs to log in to your website, he can just select the previously created passkey from the prompt, the server validates that the passkey exists and belongs to the current user, and if so user is authenticated. That’s a super brief explanation.

To better understand it, you can check these interactive demos:
https://www.webauthn.me/
https://webauthn.io/

A more detailed explanation of the exact steps will be provided in the next sections.

Pre-requisites

  • Node.js 20 (or higher) installed
  • Git installed
  • familiarity with React, Nest.js, and Prisma

FE Setup and overview

I’ve prepared some boilerplate code so that you can focus specifically on passkey implementation.

To clone the FE part of the boilerplate, use

git clone https://github.com/serafimsanvol/webauthn-frontend
Enter fullscreen mode Exit fullscreen mode

Then don’t forget to install dependencies with your preferred package manager, like

pnpm install
Enter fullscreen mode Exit fullscreen mode

As you can see, it’s just a basic React app with React Router.

We have a few routes here

  • sign up page (/) - where we fill user email to confirm
  • sign in (/sign-in) - sign in with email + passkey combination
  • protected (/protected) - route that will be hidden behind login
  • passkey sign up (/passkey) - here we verify the token from email, create a session for the user, and request to create a passkey.

It has already implemented session-based authentication and email verification routes. All API call functions are located at app/api/auth/index.ts. Take a minute to take a look.

To start in dev mode, use the command

npm run dev
Enter fullscreen mode Exit fullscreen mode

The server started on http://localhost:5173/, where you can click through existing pages (links to all of them you can find in the header). Some functionality won’t work, like logging out before we start our backend, so let’s do it.

Screenshot of the home page

BE Setup and overview

To clone the BE part of the boilerplate, use

git clone https://github.com/serafimsanvol/webauthn-backend
Enter fullscreen mode Exit fullscreen mode

Don’t forget to install dependencies with your preferred package manager

pnpm install
Enter fullscreen mode Exit fullscreen mode

Create .env file in the root folder and paste data from .env.sample for now. We’re using SQLite in this sample for simplicity.

Let’s take a quick look at the existing code. Starting with the schema.prisma file.

Here you can notice 3 models:
User - basic user data, including id, email, and whether the user's email is verified.
AuthTokens - table for short-lived tokens. In our case, we would use them to confirm that the user is actually the owner of the email.
Session - our table for long-lived tokens that the user has for authentication, an alternative to JWT (JSON Web Tokens).

prisma module - basic setup of Prisma and Prisma service. Global module to use in all our modules.

users - module for operations with users, right now we only have UsersService with 2 methods - findUserByEmail and createUser.

emails - module for sending emails. EmailsService contains a single method, sendVerificationEmail.
For this method to work, you need to sign up for an account on resend - https://resend.com/
and create API_KEY.
Paste the key into the .env file as RESEND_API_KEY
and your email with which you signed up on resend as RECIPIENT_EMAIL in the .env file.

and finally the auth module.

Note about the authentication system: it’s not a fully-featured, and is just bare-bones. If you want to use it as a template, you need to implement at least Sliding Session / Sliding Expiration and Rotating Refresh Tokens.

Before starting, you need to apply migrations to your database. This can be done using

npx prisma migrate dev
Enter fullscreen mode Exit fullscreen mode

To start the dev server, run

npm run dev 
Enter fullscreen mode Exit fullscreen mode

Passkey authentication overview

On https://www.webauthn.me/, you can find a great, interactive overview of the steps required for passkey sign-up. Let’s break them into steps here:

  • The user has to provide a username or email. In our case, that would be the email of the user after it is verified via an authentication token
  • The browser sends a request to the server to generate the signup options
  • The server responds with generated options
  • In the browser, we’re asking the user to create a new passkey
  • After that, we’re sending data about the user's passkey to the server to verify it and confirm successful sign-up.
  • We’ve done it!

For sign-in steps would be similar:

  • user providing username/email
  • We’re sending a request to the server to generate sign-in options
  • With generated data from the server, we’re initiating user interaction to provide a relevant passkey via browser/native UI
  • Sending data to the server to confirm
  • Now we’re authenticated!

Additional data structures

Let’s start with DB changes. We need to create db model for Passkeys. It would look like this:
This is based on recommended code from simplewebauthn, a library we would use for both FE and BE.

enum CredentialDeviceType {
 singleDevice
 multiDevice
}

model Passkey {
 id             String               @id @default(cuid())
 credentialId   String               @unique
 userId         String
 publicKey      Bytes
 webauthnUserID String
 counter        Int
 deviceType     CredentialDeviceType
 backedUp       Boolean @default(false)
 transports     String
 deleted        Boolean @default(false)


 createdAt DateTime @default(now())
 user      User     @relation(fields: [userId], references: [id])
}
Enter fullscreen mode Exit fullscreen mode

Additionally, we would need a model for storing challenges

enum WebauthChallengeType {
 REGISTRATION
 AUTHENTICATION
}


model WebauthChallenge {
 id        String               @id @default(cuid())
 userId    String
 challenge String               @unique
 createdAt DateTime             @default(now())
 expiresAt DateTime
 isUsed    Boolean              @default(false)
 type      WebauthChallengeType


 user User @relation(fields: [userId], references: [id])
}
Enter fullscreen mode Exit fullscreen mode

And don’t forget to update the User model

model User {
 id        String   @id @default(cuid())
 email     String   @unique
 verified  Boolean  @default(false)
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt


 authTokens AuthToken[]
 sessions   Session[]
 webauthChallenges WebauthChallenge[]
}
Enter fullscreen mode Exit fullscreen mode

let’s run

npx prisma migrate dev
Enter fullscreen mode Exit fullscreen mode

to name, run, and apply migration
And we are ready to start implementing some routes

Generate registration options

Use nest commands to setup module

nest g module passkeys --no-spec
nest g service passkeys --no-spec   
nest g controller passkeys --no-spec 
Enter fullscreen mode Exit fullscreen mode

Next, let’s create file constants.ts in passkeys folder with some necessary config data

export const RP_NAME = "Sample project name";

export const RP_ID = "localhost"; // Change this to your actual RP ID
/**
* The origin URL for the RP, used in passkey registration and authentication.
*/
export const ORIGIN = `http://${RP_ID}:5173`; // Adjust the port if necessary

export const WEBAUTHN_TIMEOUT = 60000; // 60 seconds
Enter fullscreen mode Exit fullscreen mode

This setup is valid only for local development; don’t forget to update values before deployment.

Now we need to add a dependency that would help us with authentication

pnpm install @simplewebauthn/server
Enter fullscreen mode Exit fullscreen mode

Signup options BE

Let’s add to the passkey controller a route to generate signup options. We’re finding based on the token if a session for the user exists, retrieving the user, and calling generateSignupOptions, which we’re going to implement right now

import { Controller, Get, UnauthorizedException } from "@nestjs/common";
import { Token } from "src/common/decorators/token/token.decorator";
import { PrismaService } from "src/prisma/prisma.service";
import { PasskeysService } from "./passkeys.service";


@Controller("passkeys")
export class PasskeysController {
 constructor(
   private readonly prisma: PrismaService,
   private readonly passkeysService: PasskeysService,
 ) {}


 @Get("signup-options")
 async generateSignupOptions(@Token() token: string) {
   const session = await this.prisma.session.findUnique({
     where: {
       token: token.split(".")[0],
     },
     include: {
       user: {
         select: {
           id: true,
           email: true,
           verified: true,
           createdAt: true,
           updatedAt: true,
         },
       },
     },
   });


   if (!session?.user) throw new UnauthorizedException("Session not found");


   return this.passkeysService.generateSignupOptions({ user: session.user });
 }
}
Enter fullscreen mode Exit fullscreen mode

In the passkey service create method, generateSignupOptions. Inside, we’re generating a sign-up option based on user info and default params, then creating a challenge in the database so we can confirm that this is the actual challenge that was created by our backend.

import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { User, WebauthChallengeType } from "@prisma/client";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { RP_ID, RP_NAME, WEBAUTHN_TIMEOUT } from "./constants";


@Injectable()
export class PasskeysService {
 constructor(private readonly prisma: PrismaService) {}


 async generateSignupOptions({ user }: { user: User }) {
   const options: PublicKeyCredentialCreationOptionsJSON =
     await generateRegistrationOptions({
       rpName: RP_NAME,
       rpID: RP_ID,
       userName: user.email,
       attestationType: "none",
       timeout: WEBAUTHN_TIMEOUT,
       excludeCredentials: [],
       authenticatorSelection: {
         residentKey: "preferred",
         userVerification: "preferred",
         authenticatorAttachment: "platform",
       },
       supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
     });


   await this.prisma.webauthChallenge.create({
     data: {
       userId: user.id,
       challenge: options.challenge,
       expiresAt: new Date(Date.now() + WEBAUTHN_TIMEOUT),
       isUsed: false,
       type: WebauthChallengeType.REGISTRATION,
     },
   });


   return options;
 }
}
Enter fullscreen mode Exit fullscreen mode

Now let’s implement verify signup on BE

Verify signup on BE

Let's update a passkey controller

We should receive a response in JSON format from the user on the client, and a challenge to verify it.

Update imports

import {
 Body,
 Controller,
 Get,
 Post,
 UnauthorizedException,
} from "@nestjs/common";
import { Token } from "src/common/decorators/token/token.decorator";
import { PrismaService } from "src/prisma/prisma.service";
import { PasskeysService } from "./passkeys.service";
import { RegistrationResponseJSON } from "@simplewebauthn/server";
Enter fullscreen mode Exit fullscreen mode

add a new route handler to controller

 @Post("verify-signup")
 async verifySignup(
   @Body("response") clientResponse: RegistrationResponseJSON,
   @Body("challenge") challenge: string,
   @Token() token: string,
 ) {
   return this.passkeysService.verifySignup(
     clientResponse,
     challenge,
     token.split(".")[1],
   );
 }

Enter fullscreen mode Exit fullscreen mode

verifySignup in the passkeys service should look like this. Checking if the sessionId is valid. Then check if the challenges match. Verifying registration response, and if verified, creating in db passkey.

 async verifySignup(
   clientResponse: RegistrationResponseJSON,
   challenge: string,
   sessionId: string,
 ) {
   try {
     const session = await this.prisma.session.findFirst({
       where: { id: sessionId },
     });


     if (!session) throw new UnauthorizedException("Session not found");


     const currentChallenge = await this.prisma.webauthChallenge.findFirst({
       where: {
         challenge,
         type: WebauthChallengeType.REGISTRATION,
         expiresAt: {
           gte: new Date(),
         },
         userId: session?.userId,
         isUsed: false,
       },
     });


     if (!currentChallenge) throw new Error("Invalid or expired challenge");


     const verification = await verifyRegistrationResponse({
       expectedOrigin: ORIGIN,
       expectedRPID: RP_ID,
       response: clientResponse,
       expectedChallenge: challenge,
       requireUserVerification: false,
     });


     const { verified, registrationInfo } = verification;


     if (!registrationInfo || !verified)
       throw new UnauthorizedException("Verification failed");


     const { credential } = registrationInfo;


     const { response } = clientResponse;


     const transports = response.transports
       ? response.transports.join(", ")
       : "";


     await this.prisma.passkey.create({
       data: {
         webauthnUserID: credential.id,
         publicKey: credential.publicKey,
         counter: credential.counter,
         transports,
         userId: currentChallenge.userId,
         credentialId: Buffer.from(credential.id).toString("base64"),
         deviceType: registrationInfo.credentialDeviceType,
       },
     });


     return {
       verified: verification.verified,
     };
   } catch (error) {
     console.error("Verification error:", error);
     throw error;
   }
 }

Enter fullscreen mode Exit fullscreen mode

We’ve finished the signup flow on BE. Let’s implement FE and check it out.

FE-part of the sign-up

Firstly, let’s add a new dependency

pnpm add @simplewebauthn/browser
Enter fullscreen mode Exit fullscreen mode

Now let’s add api call for our created routes in api/auth/index.ts

import { ORIGIN } from "../constants";
import type { RegistrationResponseJSON } from "@simplewebauthn/browser";


export const getSignupOptions = async () => {
 return fetch(`${ORIGIN}/passkeys/signup-options`, {
   credentials: "include",
 }).then((res) => res.json());
};


export const verifySignup = async (
 challenge: string,
 response: RegistrationResponseJSON
) => {
 return fetch(`${ORIGIN}/passkeys/verify-signup`, {
   method: "POST",
   credentials: "include",
   headers: {
     "Content-Type": "application/json",
   },
   body: JSON.stringify({
     challenge,
     response,
   }),
 });
};

Enter fullscreen mode Exit fullscreen mode

Add on click, generate sign-up options, then verify them (add this to app/routes/passkey.tsx)

import { useLoaderData, useNavigate } from "react-router";
import { startRegistration } from "@simplewebauthn/browser";
import type { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/browser";
import { getSignupOptions, verifyEmail, verifySignup } from "~/api/auth";


export async function clientLoader() {
 const urlParams = new URLSearchParams(window.location.search);
 const token = urlParams.get("token");


 const response = await verifyEmail(token || "");


 if (response.status >= 400) {
   return { message: "" };
 }


 return { message: "Email verified successfully" };
}


const Passkey = () => {
 const { message } = useLoaderData<typeof clientLoader>();
 const navigate = useNavigate();
 return (
   <div className="flex flex-col items-center justify-center min-h-[100%]">
     {message && <p className="text-green-500">{message}</p>}
     <p className="mb-4">Sign up with Passkey</p>
     <button
       onClick={async () => {
         const optionsJSON: PublicKeyCredentialCreationOptionsJSON =
           await getSignupOptions();


         const challenge = optionsJSON.challenge;


         const result = await startRegistration({
           optionsJSON,
         });


         await verifySignup(challenge, result);


         navigate("/protected");
       }}
       className="bg-blue-500 py-2 px-4 text-white p-2 rounded-md cursor-pointer"
     >
       Signup
     </button>
   </div>
 );
};


export default Passkey;

Enter fullscreen mode Exit fullscreen mode

Also, let’s finish the users/me path (on BE).

It should look like this now. We should just add a passkey check, so it’s finished

import {
 Controller,
 ForbiddenException,
 Get,
 UnauthorizedException,
} from "@nestjs/common";
import { Token } from "src/common/decorators/token/token.decorator";
import { PrismaService } from "src/prisma/prisma.service";
import { User } from "@prisma/client";
@Controller("users")
export class UsersController {
 constructor(private readonly prisma: PrismaService) {}
 @Get("me")
 async getCurrentUser(@Token() token: string): Promise<User> {
   // move to guards all of the checks
   if (!token) throw new UnauthorizedException("No session token found");


   const session = await this.prisma.session.findUnique({
     where: {
       token: token.split(".")[0],
     },
     include: {
       user: true,
     },
   });


   if (!session) throw new UnauthorizedException("Session not found");


   const passKey = await this.prisma.passkey.findFirst({
     where: {
       userId: session.user.id,
       deleted: false,
     },
   });


   if (!passKey) throw new ForbiddenException("No passkey found for the user");


   return session.user;
 }
}
Enter fullscreen mode Exit fullscreen mode

Let’s start our FE and BE by using in both folders command

pnpm run dev

Sign up a user by writing an email and sending a verification email on the home page http://localhost:5173/

Home page with send verification email form

After that, you should see a success message

Verification email sent page

Check your email.If you can’t find an email like this, check your spam folder or logs on the backend.

Screenshot of email with link to verify sign up

On the link from the email, you should be redirected to the passkey page

sign up with passkey page

Right now user is authenticated using a cookie token, and corresponding records are created in the database.

Then click on the signup button to proceed. You’d face a browser modal

Create a passkey prompt

If created successfully, you’d be redirected to the protected page

screnshot of protected page that shows after successfull sign up

That’s it, you’ve created an account and a corresponding passkey!

Now, let’s implement sign-in

Before doing that, don’t forget to log out

screenshot of protected page with cursor hovered on logout button

Now, let’s create a route to generate sign-in options. We accept email from the user and fetch all relevant passkeys for this user. Create a new route in the passkeys controller

@Post("signin-options")
 async generateSigninOptions(@Body("email") email: string) {
   const user = await this.prisma.user.findUnique({
     where: { email },
     include: {
       Passkey: true,
     },
   });


   const passkeys = user?.Passkey || [];


   return this.passkeysService.generateSigninOptions(passkeys, user?.id);
 }

Enter fullscreen mode Exit fullscreen mode

Now, let’s implement the generateSigninOptions method in the passkeys service.

Imports update

import {
 generateRegistrationOptions,
 RegistrationResponseJSON,
 verifyRegistrationResponse,
 PublicKeyCredentialRequestOptionsJSON,
 generateAuthenticationOptions,
} from "@simplewebauthn/server";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { User, WebauthChallengeType, Passkey } from "@prisma/client";
import { ORIGIN, RP_ID, RP_NAME, WEBAUTHN_TIMEOUT } from "./constants";
Enter fullscreen mode Exit fullscreen mode

And code update

 async generateSigninOptions(userPasskeys: Passkey[], userId?: string) {
   const options: PublicKeyCredentialRequestOptionsJSON =
     await generateAuthenticationOptions({
       rpID: RP_ID,
       userVerification: "required",
       // Require users to use a previously-registered authenticator
       allowCredentials: userPasskeys.map((passkey) => ({
         type: "public-key",
         // toggle here
         id: passkey.webauthnUserID,
         transports: passkey.transports.split(
           ", ",
         ) as AuthenticatorTransport[],
       })),
     });


   if (!userId) return options;


   await this.prisma.webauthChallenge.create({
     data: {
       challenge: options.challenge,
       expiresAt: new Date(Date.now() + (options.timeout || WEBAUTHN_TIMEOUT)), // Default to 60 seconds if timeout is not set
       isUsed: false,
       type: WebauthChallengeType.AUTHENTICATION,
       userId,
     },
   });


   return options;
 }

Enter fullscreen mode Exit fullscreen mode

We’re requiring userVerification here and passing already created passkeys as allowed credentials. After that, create in the DB data about the challenge and authentication type.

Now, let’s implement the sign-in verification route (in the passkeys controller)

But before that let’s import AuthModule to Passkeys module

import { Module } from "@nestjs/common";
import { PasskeysService } from "./passkeys.service";
import { PasskeysController } from "./passkeys.controller";
import { AuthModule } from "src/auth/auth.module";


@Module({
 providers: [PasskeysService],
 controllers: [PasskeysController],
 imports: [AuthModule],
})
export class PasskeysModule {}
Enter fullscreen mode Exit fullscreen mode

And update passkeys controller constructor

constructor(
   private readonly prisma: PrismaService,
   private readonly passkeysService: PasskeysService,
   private readonly authService: AuthService,
 ) {}

Enter fullscreen mode Exit fullscreen mode

Then update imports

import {
 Body,
 Controller,
 Get,
 Post,
 Res,
 UnauthorizedException,
} from "@nestjs/common";
import {
 RegistrationResponseJSON,
 AuthenticationResponseJSON,
} from "@simplewebauthn/server";
import { Response } from "express";
import { PrismaService } from "src/prisma/prisma.service";
import { PasskeysService } from "./passkeys.service";
import { Token } from "src/common/decorators/token/token.decorator";
import { AuthService } from "src/auth/auth.service";
Enter fullscreen mode Exit fullscreen mode

Add a route where we call verifySignin from the service, and on success, set a cookie and send a success message.

 @Post("verify-signin")
 async verifySignin(
   @Body("response") clientResponse: AuthenticationResponseJSON,
   @Body("challenge") challenge: string,
   @Res({ passthrough: true }) response: Response,
 ) {
   const { verified, userId } = await this.passkeysService.verifySignin(
     clientResponse,
     challenge,
   );


   if (!verified) throw new UnauthorizedException("Verification failed");


   const sessionToken = await this.authService.generateSessionToken(userId);


   response.cookie("token", `${sessionToken.token}.${sessionToken.id}`, {
     httpOnly: true,
     secure: false,
     // lax for development, none with secure for production
     sameSite: "lax",
     path: "/",
   });


   return {
     message: "Signin successful",
   };
 }

Enter fullscreen mode Exit fullscreen mode

And the method in the passkey service should look like this
Imports

import {
 generateRegistrationOptions,
 RegistrationResponseJSON,
 verifyRegistrationResponse,
 PublicKeyCredentialRequestOptionsJSON,
 generateAuthenticationOptions,
 verifyAuthenticationResponse,
 AuthenticationResponseJSON,
} from "@simplewebauthn/server";
Enter fullscreen mode Exit fullscreen mode

And code where we check if the challenge is valid and belongs to the user. Then, check do we have a passkey with the ID from the user response on the client and verify all data. On success, invalidating the corresponding webauthChallenge

async verifySignin(
   clientResponse: AuthenticationResponseJSON,
   challenge: string,
 ): Promise<{ verified: boolean; userId: string }> {
   try {
     const currentChallenge = await this.prisma.webauthChallenge.findFirst({
       where: {
         challenge,
         type: WebauthChallengeType.AUTHENTICATION,
         expiresAt: {
           gte: new Date(),
         },
         isUsed: false,
       },
       include: {
         user: true, // Include user to get userId
       },
     });


     if (!currentChallenge)
       // TODO think about more specific error
       throw new UnauthorizedException("Invalid or expired challenge");


     const passKey = await this.prisma.passkey.findFirst({
       where: { webauthnUserID: clientResponse.id },
     });


     if (!passKey) {
       throw new UnauthorizedException("Passkey not found");
     }


     const verification = await verifyAuthenticationResponse({
       response: clientResponse,
       expectedChallenge: currentChallenge.challenge,
       expectedOrigin: ORIGIN,
       expectedRPID: RP_ID,
       requireUserVerification: true,
       credential: {
         id: passKey.webauthnUserID,
         publicKey: new Uint8Array(passKey.publicKey),
         counter: passKey.counter as unknown as number,
         transports: passKey.transports.split(
           ", ",
         ) as AuthenticatorTransport[],
       },
     });


     if (!verification.verified) {
       throw new UnauthorizedException("Verification failed");
     }


     await this.prisma.webauthChallenge.update({
       where: { id: currentChallenge.id },
       data: { isUsed: true },
     });


     return {
       verified: verification.verified,
       userId: currentChallenge.userId,
     };
   } catch (error) {
     console.error("Verification error:", error);
     throw error;
   }
 }

Enter fullscreen mode Exit fullscreen mode

That’s it on BE. Let’s move to FE

FE sign-in

We need to add fetches to the created routes.

Imports update (to the api/auth/index.ts)

import type {
 RegistrationResponseJSON,
 AuthenticationResponseJSON,
 PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/browser";
Enter fullscreen mode Exit fullscreen mode

And code



export const getSigninOptions = async (email: string) => {
 const optionsJSON: PublicKeyCredentialRequestOptionsJSON = await fetch(
   `${ORIGIN}/passkeys/signin-options`,
   {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
     },
     body: JSON.stringify({ email }),
   }
 ).then((res) => res.json());


 return optionsJSON;
};


export const verifySignin = async (
 result: AuthenticationResponseJSON,
 challenge: string
) => {
 return fetch(`${ORIGIN}/passkeys/verify-signin`, {
   method: "POST",
   credentials: "include",
   headers: {
     "Content-Type": "application/json",
   },
   body: JSON.stringify({
     response: result,
     challenge: challenge,
   }),
 });
};

Enter fullscreen mode Exit fullscreen mode

Now move to app/routes/signin.tsx

Update imports

import { startAuthentication } from "@simplewebauthn/browser";
import { useForm, type SubmitHandler } from "react-hook-form";
import { redirect, useNavigate } from "react-router";
import { getSigninOptions, getUser, verifySignin } from "~/api/auth";
Enter fullscreen mode Exit fullscreen mode

And update the onSubmit handler with our fetches

const navigate = useNavigate();
 const onSubmit: SubmitHandler<SigninFormData> = async (data) => {
   const email = data.email;
   const optionsJSON = await getSigninOptions(email);


   const result = await startAuthentication({
     optionsJSON: optionsJSON,
   });


   const response = await verifySignin(result, optionsJSON.challenge);


   if (response.status < 400) return navigate("/protected");
 };

Enter fullscreen mode Exit fullscreen mode

Now go to the /signin page, write your email, and click sign in

sign in page with input for email and sign in button

You’d get a verification prompt Touch ID/password, depends on your machine

Enter your PIN prompt

And after success, you’d be redirected to a protected page

protected page

Here it is, not-so-not-so-short sample of the implementation of WebAuthn.
If you want to learn more about it, because there are a lot of tweaks and caveats, check out:
https://webauthn.guide/
Also, many thanks to https://simplewebauthn.dev/ and its author Matthew Miller
for libraries and documentation that made writing this article and implementing WebAuthn much easier

Top comments (0)