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
Then don’t forget to install dependencies with your preferred package manager, like
pnpm install
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
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.
BE Setup and overview
To clone the BE part of the boilerplate, use
git clone https://github.com/serafimsanvol/webauthn-backend
Don’t forget to install dependencies with your preferred package manager
pnpm install
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
To start the dev server, run
npm run dev
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])
}
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])
}
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[]
}
let’s run
npx prisma migrate dev
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
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
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
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 });
}
}
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;
}
}
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";
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],
);
}
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;
}
}
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
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,
}),
});
};
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;
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;
}
}
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/
After that, you should see a success message
Check your email.If you can’t find an email like this, check your spam folder or logs on the backend.
On the link from the email, you should be redirected to the 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
If created successfully, you’d be redirected to the protected page
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
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);
}
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";
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;
}
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 {}
And update passkeys controller constructor
constructor(
private readonly prisma: PrismaService,
private readonly passkeysService: PasskeysService,
private readonly authService: AuthService,
) {}
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";
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",
};
}
And the method in the passkey service should look like this
Imports
import {
generateRegistrationOptions,
RegistrationResponseJSON,
verifyRegistrationResponse,
PublicKeyCredentialRequestOptionsJSON,
generateAuthenticationOptions,
verifyAuthenticationResponse,
AuthenticationResponseJSON,
} from "@simplewebauthn/server";
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;
}
}
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";
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,
}),
});
};
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";
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");
};
Now go to the /signin page, write your email, and click sign in
You’d get a verification prompt Touch ID/password, depends on your machine
And after success, you’d be redirected to a 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)