passkey tutorial how to implement passkeysPasskeys Implementation

Passkey Tutorial: How to Implement Passkeys in Web Apps

This tutorial explains how to implement passkeys in your web app. We use Node.js (TypeScript), SimpleWebAuthn, Vanilla HTML / JavaScript and MySQL.

Blog-Post-Author

Vincent

Created: December 7, 2023

Updated: October 1, 2024


We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.

Overview#

1. Introduction: How To Implement Passkeys

2. Prerequisites To Integrate Passkeys

    2.1 Frontend: Vanilla HTML & JavaScript

    2.2 Backend: Node.js (Express) in TypeScript + SimpleWebAuthn

    2.3 Database: MySQL

3. Architecture Overview: Passkey Example Implementation

4. Setup of the MySQL Database

5. Implementing Passkeys: Backend Integration Steps

    5.1 Initialize Node.js (Express) Server

    5.2 MySQL Database Connection

    5.3 App Server Configuration

    5.4 Credential Service & User Service

    5.5 Middleware

    5.6 Utilities

    5.7 Passkey Controllers with SimpleWebAuthn

    5.8 Passkey Routes

6. Integrate Passkeys into the Frontend

7. Run the Passkey Example App

8. Passkey DevOps Integration

9. Additional Passkey Tips for Developers

10. Conclusion: Passkey Tutorial

1. Introduction: How To Implement Passkeys

In this tutorial, we help you in your passkeys implementation efforts, offering a step-by-step guide on how to add passkeys to your website.

Having a modern, robust and user-friendly authentication is key when you want to build a great website or app. Passkeys have emerged as the answer to this challenge. Serving as the new standard for logins, they promise a future without the disadvantages of traditional passwords, providing a genuinely passwordless login experience (which is not only secure but also highly convenient).

What truly expresses the potential of passkeys is the endorsement they have garnered. Every significant browser be it Chrome, Firefox, Safari, or Edge and all important device manufacturers (Apple, Microsoft, Google) have incorporated support. This unanimous embrace showcases that passkeys are the new standard for logins.

Yes, there are already tutorials on integrating passkeys into web applications. Be it for frontend frameworks like React, Vue.js or Next.js, there's a plethora of guides designed to mitigate challenges and speed up your passkey implementations. However, an end-2-end tutorial that remains minimalistic and bare-metal is lacking. Many developers have approached us and asked for a tutorial that brings clarity into passkeys implementation for web apps.

This is precisely why we've crafted this guide. Our objective? To create a minimal viable setup for passkeys, encompassing the frontend, backend and database layer (the latter one often neglected even though it can cause some serious headaches).

Analyzer Icon

Are your users passkey-ready?

Test Passkey-Readiness

By the end of this journey, you will have built a minimum viable web application, where you can:

  • Create a passkey
  • Use the passkey to login

For those in a rush or wanting a reference, the entire codebase is available on GitHub.

Curious how the final result looks like? Here's a sneak peek of the final project (we admit it looks very basic but the interesting stuff is under the surface):

Passkey Tutorial Login Screen

We're fully aware that parts of the code and project can be done differently or more sophisticated but we wanted to focus on the essentials. That's why we intentionally kept things simple and passkey-centered.

How to add passkeys to my production website?

This is a very minimal example for passkey authentication. The following things are NOT considered / implemented in this tutorial or only very basic:

Getting full support for all these features requires tremendously more development effort. For those interesed, we recommend a look at this passkeys developer misconception article.

Slack Icon

Become part of our Passkeys Community for updates and support.

Join

2. Prerequisites to Integrate Passkeys

Before diving deep into the passkey implementation, let's have a look at the necessary skills and tools. Here's what you need to get started:

2.1 Frontend: Vanilla HTML & JavaScript

A solid grasp of the building blocks of the web HTML, CSS, and JavaScript is essential. We've intentionally kept things straightforward, refraining from any modern JavaScript framework and relied on Vanilla JavaScript / HTML. The only more sophisticated thing we use is the WebAuthn wrapper library @simplewebauthn/browser.

2.2 Backend: Node.js (Express) in TypeScript + SimpleWebAuthn

For our backend, we use a Node.js (Express) server written in TypeScript. We've also decided to work with SimpleWebAuthn's WebAuthn server implementation (@simplewebauthn/server together with @simplewebauthn/typescript-types). There are numerous WebAuthn server implementations available, so you can of course also use any of these. As we have decided for the TypeScript WebAuthn server, basic Node.js and npm knowledge is required.

2.3 Database: MySQL

All user data and public keys of the passkeys are stored in a database. We've selected MySQL as database technology. A foundational understanding of MySQL and relational databases is beneficial, though we'll guide you through the single steps.

In the following, we often use the terms WebAuthn and passkeys interchangeable even though they might not officially mean the same. For better understanding, especially in the code part, we make this assumption though.

With these prerequisites in place, you're all set to dive into the world of passkeys.

Ben Gould Testimonial

Ben Gould

Head of Engineering

I’ve built hundreds of integrations in my time, including quite a few with identity providers and I’ve never been so impressed with a developer experience as I have been with Corbado.

10,000+ devs trust Corbado & make the Internet safer with passkeys. Got questions? We’ve written 150+ blog posts on passkeys.

Join Passkeys Community

3. Architecture Overview: Passkey Example Implementation

Before going into the code and configurations, let's have a view on the architecture of the system we want to build. Here's a breakdown of the architecture we'll be setting up:

  • Frontend: It consists of two buttons one for user registration (creating a passkey) and the other for authentication (logging in using the passkey).
  • Device & Browser: Once an action is triggered on the frontend, the device and the browser come into play. They facilitate the creation and verification of the passkey, acting as intermediaries between the user and the backend.
  • Backend: The backend is where the real magic unfolds in our application. It handles all the requests initiated by the frontend. This process involves creating and verifying passkeys. At the core of the backend operations is the WebAuthn server. Contrary to what the name might suggest, it's not a standalone server. Instead, it's a library or package that implements the WebAuthn standard. The two primary functions are: Registration (Sign-up) where new users create their passkeys and Authentication (Login): Where existing users log in using their passkeys. In its simplest form, the WebAuthn server provides four public API endpoints, divided into two categories: two for registration and two for authentication. They are designed to receive data in a specific format, which is then processed by the WebAuthn server. The WebAuthn server is responsible for all the necessary cryptographic operations. An essential aspect to note is that these API endpoints must be served over HTTPS.
  • MySQL Database: Acting as our storage backbone, the MySQL database is responsible for holding user data and their corresponding credentials.

With this architectural overview, you should have a conceptual map of how the components of our application. As we proceed, we'll dive deeper into each of these components, detailing their setup, configuration, and interplay.

The following chart describes the process flow during register (sign-up):

Passkey Sign-Up Process Chart

The following chart describes the process flow during authentication (login):

Passkey Login Process Chart

Moreover, you find the project structure here (only the most important files):

passkeys-tutorial ├── src # Contains all backend TypeScript source code │ ├── controllers # Business logic for handling specific types of requests │ │ ├── authentication.ts # Passkey authentication logic │ │ └── registration.ts # Passkey registration logic │ ├── middleware │ │ ├── customError.ts # Add custom error messages in standardized manner │ │ └── errorHandler.ts # General error handler │ ├── public │ │ ├── index.html # Main HTML file in the frontend │ │ ├── css │ │ │ └── style.css # Basic styling │ │ └── js │ │ └── script.js # JavaScript logic (incl. WebAuthn API) │ ├── routes # Definitions of API routes and their handlers │ │ └── routes.ts # Specific passkey routes │ ├── services │ │ ├── credentialService.ts# Interacts with credential table │ │ └── userService.ts # Interacts with user table │ ├── utils # Helper functions and utilities │ | ├── constants.ts # Some constants (e.g. rpID) │ | └── utils.ts # Helper function │ ├── database.ts # Creates the connection from Node.js to the MySQL database │ ├── index.ts # Entrypoint of the Node.js server │ └── server.ts # Manages all the server settings ├── config.json # Some configurations for the Node.js project ├── docker-compose.yml # Defines services, networks, and volumes for Docker containers ├── Dockerfile # Creates a Docker image of the project ├── init-db.sql # Defines our MySQL database scheme ├── package.json # Manages Node.js project dependencies and scripts └── tsconfig.json # Configures how TypeScript compiles your code

4. Setup of the MySQL Database

When implementing passkeys, the database setup is a key component. Our approach uses a Docker container running MySQL, offering a straightforward and isolated environment essential for reliable testing and deployment.

Our database scheme is intentionally minimalistic, featuring just two tables. This simplicity aids in a clearer understanding and easier maintenance.

Detailed Table Structure

1. Credentials Table: Central to passkey authentication, this table stores the passkey credentials. Critical Columns:

  • credential_id: A unique identifier for each credential. Selecting the correct data type for this field is vital to avoid formatting errors.
  • public_key: Stores the public key for each credential. As with credential_id, appropriate data type and formatting are crucial.

2. Users Table: Links user accounts to their corresponding credentials.

Note that we named the first table credentials as this is according to our experience and what other libraries recommend more suitable (contrary to SimpleWebAuthn's suggestion to name it authenticator or authenticator_device).

The data types for credential_id and public_key are crucial. Errors often arise from incorrect data types, encoding or formatting (especially the difference between Base64 and Base64URL is a common cause of errors), which can disrupt the entire registration (sign-up) or authentication (login) process.

All necessary SQL commands for setting up these tables are contained within the init-db.sql file. This script ensures a quick and error-free database initialization.

For more sophisticated cases, you can add credential_device_type or credential_backed_up to store more information about the credentials and improve the user experience. We refrain from that in this tutorial though.

init-db.sql
CREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users(id) );

After we have created this file, we create a new docker-compose.yml file on the root level of the project:

docker-compose.yml
version: '3.1' services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql

This file starts the MySQL database on port 3306 and creates the defined database structure. It's important to note that the name and password for the database used here are kept simple for demonstration purposes. In a production environment, you should use more complex credentials for enhanced security.

Next, we move on to running our Docker container. At this point, our docker- compose.yml file only includes this single container, but we'll add more components later. To start the container, use the following command:

docker compose up -d

Once the container is up and running, we need to verify if the database is functioning as expected. Open a terminal and execute the following command to interact with the MySQL database:

docker exec -it <container ID> mysql -uroot -p

You'll be prompted to enter the root password, which is my-secret-pw in our example. After logging in, select the webauthn_db database and display the tables using these commands:

use webauthn_db; show tables;

At this stage, you should see the two tables defined in our script. Initially, these tables will be empty, indicating that our database setup is complete and ready for the next steps in implementing passkeys.

5. Implementing Passkeys: Backend Integration Steps

The backend is the core of any passkey application, acting as the central hub for processing user authentication requests from the frontend. It communicates with the WebAuthn server library for handling registration (sign- up) and authentication (login) requests , and it interacts with your MySQL database to store and retrieve user credentials. Below, we'll guide you through setting up your backend using Node.js (Express) with TypeScript which will expose a public API to handle all requests.

5.1 Initialize Node.js (Express) Server

First, create a new directory for your project and navigate into it using your terminal or command prompt.

Run the command

npx create-express-typescript-application passkeys-tutorial

This creates a basic code skeleton of a Node.js (Express) app written in TypeScript that we can use for further adaptions.

Your project requires several key packages that we need to install on top:

  • @simplewebauthn/server: A server-side library to facilitate WebAuthn operations, such as user registration (sign-up) and authentication (login).
  • express-session: Middleware for Express.js to manage sessions, storing server-side session data and handling cookies.
  • uuid: A utility to generate universally unique identifiers (UUIDs), commonly used for creating unique keys or identifiers in applications.
  • mysql2: A Node.js client for MySQL, providing capabilities to connect and execute queries against MySQL databases.

Switch into the new directory and install them with the following commands (we also install the required TypeScript types):

cd passkeys-tutorial
npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid

To confirm that everything is installed correctly, run

npm run dev:nodemon

This should start your Node.js server in development mode with Nodemon, which automatically restarts the server upon any file changes.

Passkey Tutorial Start App

Troubleshooting tip: If you encounter errors, try updating ts-node to version 10.8.1 in the package.json file and then run npm i to install the updates.

Your server.ts file has the basic setup and middleware for an Express application. To integrate passkey functionality, you'll need to add:

  • Routes: Define new routes for passkey registration (sign-up) and authentication (login).
  • Controllers: Create controllers to handle logic for these routes.
  • Middleware: Integrate middleware for request and error handling.
  • Services: Build services to retrieve and store data in the database.
  • Utility Functions: Include utility functions for efficient code operations.

These enhancements are key to enabling passkey authentication in your application's backend. We set them up later.

Debugger Icon

Want to experiment with passkey flows? Try our Passkeys Debugger.

Try for Free

5.2 MySQL Database Connection

After we created and started the database in section 4, we now need to make sure that our backend can connect to the MySQL database. Therefore, we create a new database.ts file in the `/src folder and add the following content:

database.ts
import mysql from 'mysql2'; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0 }); // Promisify for Node.js async/await. export const promisePool = pool.promise();

This file will later be used by our server to access the database.

5.3 App Server Configuration

Let's have a brief look at our `config.json, where two variables are already defined: the port where we run the application on and the environment:

config.json
{ "PORT": 8080, "NODE_ENV": "development" }

package.json can stay as is and should look like:

package.json
{ "name": "passkeys-tutorial", "version": "0.0.1", "description": "passkeys-tutorial initialised with create-express-typescript-application.", "main": "src/index.ts", "scripts": { "build": "tsc", "start": "node ./build/src/index.js", "dev": "ts-node ./src/index.ts", "dev:nodemon": "nodemon -w src -e ts,json -x ts-node ./src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ "express", "typescript" ], "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/node": "^14.18.63", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "nodemon": "^2.0.22", "ts-node": "^10.8.1", "typescript": "^4.9.5" }, "dependencies": { "@simplewebauthn/server": "^8.3.5", "@types/express-session": "^1.17.10", "@types/uuid": "^9.0.7", "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.18.2", "express-session": "^1.17.3", "fs": "^0.0.1-security", "helmet": "^4.6.0", "morgan": "^1.10.0", "mysql2": "^3.6.5", "uuid": "^9.0.1" } }

index.ts looks like:

index.ts
import app from './server'; import config from '../config.json'; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info('Express application started on port: ' + port); });

In server.ts, we need to adapt some more things. Moreover, a temporary cache of some sort (e.g. redis, memcache or express-session) is needed to store temporary challenges that users can authenticate against. We decided to use express-session and declare the express-session module on top to get things working with `express-session. Additionally, we streamline the routing and remove the error handling for now (this will be added to the middleware later):

server.ts
import express, {Express} from 'express'; import morgan from 'morgan'; import helmet from 'helmet'; import cors from 'cors'; import config from '../config.json'; import {router as passkeyRoutes} from './routes/routes'; import session from 'express-session'; const app: Express = express(); declare module 'express-session' { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set('json spaces', 4); app.use(express.json()); app.use(express.urlencoded({extended: true})); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === 'development' || config.NODE_ENV === 'development') { app.use(morgan('dev')); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === 'production' || config.NODE_ENV === 'production') { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use('/api/passkey', passkeyRoutes); app.use(express.static("src/public")); export default app;

5.4 Credential Service & User Service

To effectively manage the data in our two created tables, we'll develop two distinct services in a new src/services directory: authenticatorService.ts and `userService.ts.

Each service will encapsulate CRUD (Create, Read, Update, Delete) methods, enabling us to interact with the database in a modular and organized way. These services will facilitate storing, retrieving, and updating data in the authenticator and user tables. Here's how the structure of these required files should be laid out:

userService.ts looks like this:

userService.ts
import { promisePool } from '../database'; // Adjust the import path as necessary import { v4 as uuidv4 } from 'uuid'; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query('SELECT * FROM users WHERE id = ?', [userId]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query('SELECT * FROM users WHERE username = ?', [username]); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query('INSERT INTO users (id, username) VALUES (?, ?)', [id, username]); return { id, username }; } };

credentialService.ts looks as follows:

credentialService.ts
import {promisePool} from '../database'; import type {AuthenticatorDevice} from '@simplewebauthn/typescript-types'; export const credentialService = { async saveNewCredential(userId: string, credentialId: string, publicKey: string, counter: number, transports: string) { try { await promisePool.query( 'INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)', [userId, credentialId, publicKey, counter, transports] ); } catch (error) { console.error('Error saving new credential:', error); throw error; } }, async getCredentialByCredentialId(credentialId: string): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query('SELECT * FROM credentials WHERE credential_id = ? LIMIT 1', [credentialId]); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(',') : [], } as AuthenticatorDevice; } catch (error) { console.error('Error retrieving credential:', error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( 'UPDATE credentials SET counter = ? WHERE credential_id = ?', [newCounter, credentialId] ); } catch (error) { console.error('Error updating credential counter:', error); throw error; } } };

5.5 Middleware

For handling errors centrally and also making debugging easiert, we add an errorHandler.ts file:

errorHandler.ts
import { Request, Response, NextFunction } from 'express'; import { CustomError } from './customError'; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = (err: CustomError, req: Request, res: Response, next: NextFunction) => { const statusCode = err.statusCode || 500; const message = err.message || 'Internal Server Error'; console.log(message) res.status(statusCode).send({ error: message }); };

Besides, we add a new customError.ts file as we later want to be able to create custom errors to help us find bugs quicker:

customError.ts
export class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }

5.6 Utilities

In the utils folder, we create two files constants.ts and utils.ts.

constant.ts holds some basic WebAuthn server information, like relying party name, relying party ID and origin:

constant.ts
export const rpName: string = 'Passkeys Tutorial'; export const rpID: string = 'localhost'; export const origin: string = `http://${rpID}:8080`;

utils.ts holds two functions we later need to encoding and decoding data:

utils.ts
export const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString('base64'); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, 'base64'));

5.7 Passkey Controllers with SimpleWebAuthn

Now, we come to the heart of our backend: the controllers. We create two controllers, one for creating a new passkey (registration.ts) and one for logging in with a passkey (`authentication.ts).

registration.ts looks like this:

registration.ts
import {generateRegistrationOptions, verifyRegistrationResponse} from '@simplewebauthn/server'; import {uint8ArrayToBase64} from '../utils/utils'; import {rpName, rpID, origin} from '../utils/constants'; import {credentialService} from '../services/credentialService'; import {userService} from '../services/userService' import {RegistrationResponseJSON} from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from 'express'; import { CustomError } from '../middleware/customError'; export const handleRegisterStart = async (req: Request, res: Response, next: NextFunction) => { const {username} = req.body; if (!username) { return next(new CustomError('Username empty', 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError('User already exists', 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: 'direct', excludeCredentials: [], authenticatorSelection: { residentKey: 'preferred', }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next(error instanceof CustomError ? error : new CustomError('Internal Server Error', 500)); } }; export const handleRegisterFinish = async (req: Request, res: Response, next: NextFunction) => { const {body} = req; const {currentChallenge, loggedInUserId} = req.session; if (!loggedInUserId) { return next(new CustomError('User ID is missing', 400)); } if (!currentChallenge) { return next(new CustomError('Current challenge is missing', 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const {credentialPublicKey, credentialID, counter} = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports); res.send({verified: true}); } else { next(new CustomError('Verification failed', 400)); } } catch (error) { next(error instanceof CustomError ? error : new CustomError('Internal Server Error', 500)); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };

Let's review the functionalities of our controllers, which handle the two key endpoints in the WebAuthn registration (sign-up) process. This is also where one of the biggest differences to password based authentication lies: For every register (sign-up) or authentication (login) attempt, two backend API calls are required, which require specific frontend content in between. Passwords usually only need one endpoint.

1. handleRegisterStart Endpoint:

This endpoint is triggered by the frontend, receiving a username to create a new passkey and account. In this example, we only allow creation of a new account / passkey if there is no account existing yet. In real-world applications, you would need to handle this in a way that users are told that a passkey is already existing and adding from the same device is not possible (but the user could passkeys from a different device after some form of confirmation). For simplicity, we overlook this in this tutorial.

The PublicKeyCredentialCreationOptions are prepared. residentKey is set to preferred, and attestationType to direct, gathering more data from the authenticator for potential database storage.

In general, the PublicKeyCredentialCreationOptions consist of the following data:

dictionary PublicKeyCredentialCreationOptions { required PublicKeyCredentialRpEntity rp; required PublicKeyCredentialUserEntity user; required BufferSource challenge; required sequence<PublicKeyCredentialParameters> pubKeyCredParams; unsigned long timeout; sequence<PublicKeyCredentialDescriptor> excludeCredentials = []; AuthenticatorSelectionCriteria authenticatorSelection; DOMString attestation = "none"; AuthenticationExtensionsClientInputs extensions; };
  • rp: Represents the relying party (website or service) information, typically including its name (rp.name) and the domain (rp.id).
  • user: Contains user account details like user.name, user.id, and user.displayName.
  • challenge: A secure, random value created by the WebAuthn server to prevent replay attacks during the registration process.
  • pubKeyCredParams: Specifies the type of public key credential to be created, including the cryptographic algorithm used.
  • timeout: Optional, sets the time in milliseconds the user has to complete the interaction.
  • excludeCredentials: A list of credentials to be excluded; used to prevent registering a passkey for the same device / authenticator multiple times.
  • authenticatorSelection: Criteria to select the authenticator, such as whether it must support user verification or how resident keys should be encouraged.
  • attestation: Specifies the desired attestation conveyance preference, like "none", "indirect", or "direct".
  • extensions: Optional, allows for additional client extensions.

The User ID and challenge are stored in a session object, simplifying the process for tutorial purposes. Moreover, the session is cleared after each registration (sign-up) or authentication (login) attempt.

2. handleRegisterFinish Endpoint:

This endpoint retrieves the user ID and challenge set earlier. It verifies the RegistrationResponse with the challenge. If valid, it stores a new credential for the user. Once stored in the database, the user ID and challenge are removed from the session.

Tip: When debugging your application, we highly recommend to use Chrome as browser and its built-in features to improve the developer experience of passkey based applications, e.g. virtual WebAuthn authenticator and device log (see our tips for developers below for more information)

Next, we move to authentication.ts, which has a similar structure and functionality.

authentication.ts looks like this:

authentication.ts
import { Request, Response, NextFunction } from 'express'; import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'; import { uint8ArrayToBase64, base64ToUint8Array } from '../utils/utils'; import { rpID, origin } from '../utils/constants'; import {credentialService} from '../services/credentialService'; import {userService} from '../services/userService' import {AuthenticatorDevice} from "@simplewebauthn/typescript-types"; import {isoBase64URL} from "@simplewebauthn/server/helpers"; import {VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts} from "@simplewebauthn/server/esm"; import { CustomError } from '../middleware/customError'; export const handleLoginStart = async (req: Request, res: Response, next: NextFunction) => { const {username} = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError('User not found', 404)); } req.session.loggedInUserId = user.id; // allowCredentials is purposely for this demo left empty. This causes all existing local credentials // to be displayed for the service instead only the ones the username has registered. const options = await generateAuthenticationOptions({ timeout: 60000, allowCredentials: [], userVerification: 'required', rpID, }); req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next(error instanceof CustomError ? error : new CustomError('Internal Server Error', 500)); } }; export const handleLoginFinish = async (req: Request, res: Response, next: NextFunction) => { const {body} = req; const {currentChallenge, loggedInUserId} = req.session; if (!loggedInUserId) { return next(new CustomError('User ID is missing', 400)); } if (!currentChallenge) { return next(new CustomError('Current challenge is missing', 400)); } try { const credentialID = isoBase64URL.toBase64(body.rawId); const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); const dbCredential : AuthenticatorDevice | null = await credentialService.getCredentialByCredentialId(credentialID); if (!dbCredential) { return next(new CustomError('Credential not registered with this site', 404)); } // @ts-ignore const user = await userService.getUserById(dbCredential.userID); if (!user) { return next(new CustomError('User not found', 404)); } // @ts-ignore dbCredential.credentialID = base64ToUint8Array(dbCredential.credentialID) // @ts-ignore dbCredential.credentialPublicKey = base64ToUint8Array(dbCredential.credentialPublicKey) let verification: VerifiedAuthenticationResponse; const opts: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: dbCredential, }; verification = await verifyAuthenticationResponse(opts); const { verified, authenticationInfo } = verification; if (verified) { await credentialService.updateCredentialCounter( uint8ArrayToBase64(bodyCredIDBuffer), authenticationInfo.newCounter ); res.send({verified: true}); } else { next(new CustomError('Verification failed', 400)); } } catch (error) { next(error instanceof CustomError ? error : new CustomError('Internal Server Error', 500)); } finally { req.session.currentChallenge = undefined; req.session.loggedInUserId = undefined; } };

Our authentication (login) process involves two endpoints:

1. handleLoginStart Endpoint:

This endpoint is activated when a user attempts to log in. It first checks if the username exists in the database, returning an error if not found. In a real-world scenario, you might offer to create a new account instead.

For existing users, it retrieves the user ID from the database, stores it in the session, and generates PublicKeyCredentialRequestOptions options. allowCredentials is left empty to avoid restricting credential usage. That's why all available passkeys for this relying party can be selected in the passkey modal.

The generated challenge is also stored in the session and the PublicKeyCredentialRequestOptions are sent back to the frontend.

The PublicKeyCredentialRequestOptions consist of the following data:

dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
  • challenge: A secure, random value from the WebAuthn server used to prevent replay attacks during the authentication process.
  • timeout: Optional, sets the time in milliseconds the user has to respond to the authentication request.
  • rpId: The relying party ID, typically the domain of the service.
  • allowCredentials: An optional list of credential descriptors, specifying which credentials may be used for this authentication (login).
  • userVerification: Specifies the requirement for user verification, like "required", "preferred", or "discouraged".
  • extensions: Optional, allows for additional client extensions.

2. handleLoginFinish Endpoint:

This endpoint retrieves the currentChallenge and loggedInUserId from the session.

It queries the database for the right credential using the credential ID from the body. If the credential is found, this means that the user associated with this credential ID can now be authenticated (logged in). Then, we can query the user from the user table via the user ID we get from the credential and verify the authenticationResponse using the challenge and request body. If everything is successful, we show the login success message. If no matching credential is found, an error is sent.

Additionally, if the verification succeeds, the credential's counter is updated, the used challenge and loggedInUserId are removed from the session.

On top of that, we can delete the src/app and src/constant folder together with all files in there.

Note: Proper session management and route protection, crucial in real-life applications, are omitted here for simplicity in this tutorial.

5.8 Passkey Routes

Last but not least, we need to make sure that our controllers are reachable by adding the appropriate routes to routes.ts which is in a new directory src/routes:

routes.ts
import express from 'express'; import { handleError } from '../middleware/errorHandler'; import { handleRegisterStart, handleRegisterFinish } from '../controllers/registration'; import { handleLoginStart, handleLoginFinish } from '../controllers/authentication'; const router = express.Router(); router.post('/registerStart', handleRegisterStart); router.post('/registerFinish', handleRegisterFinish); router.post('/loginStart', handleLoginStart); router.post('/loginFinish', handleLoginFinish); router.use(handleError); export { router };
Substack Icon

Subscribe to our Passkeys Substack for the latest news, insights and strategies.

Subscribe

6. Integrate Passkeys Into the Frontend

This part of the passkeys tutorial focuses on how to support passkeys in the frontend of your application. We have a very basic frontend consisting of three files: index.html, styles.css and script.js. All three files are in a new src/public folder

The index.html file contains an input field for the username and two buttons to register and login. Moreover, we import the @simplewebauthn/browser script which simplifies the interaction with the browser Web Authentication API in the `js/script.js file.

index.htmllooks like this:

index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Passkey Tutorial</title> <link rel="stylesheet" href="css/style.css"> </head> <body> <div class="container"> <h1>Passkey Tutorial</h1> <div id="message"></div> <div class="input-group"> <input type="text" id="username" placeholder="Enter username"> <button id="registerButton">Register</button> <button id="loginButton">Login</button> </div> </div> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script> <script src="js/script.js"></script> </body> </html>

script.js looks as follows:

script.js
document.getElementById('registerButton').addEventListener('click', register); document.getElementById('loginButton').addEventListener('click', login); function showMessage(message, isError = false) { const messageElement = document.getElementById('message'); messageElement.textContent = message; messageElement.style.color = isError ? 'red' : 'green'; } async function register() { // Retrieve the username from the input field const username = document.getElementById('username').value; try { // Get registration options from your server. Here, we also receive the challenge. const response = await fetch('/api/passkey/registerStart', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: username}) }); console.log(response) // Check if the registration options are ok. if (!response.ok) { throw new Error('User already exists or failed to get registration options from server'); } // Convert the registration options to JSON. const options = await response.json(); console.log(options) // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new attestation is created. This also means a new public-private-key pair is created. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Send attestationResponse back to server for verification and storage. const verificationResponse = await fetch('/api/passkey/registerFinish', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(attestationResponse) }); if (verificationResponse.ok) { showMessage('Registration successful'); } else { showMessage('Registration failed', true); } } catch (error) { showMessage('Error: ' + error.message, true); } } async function login() { // Retrieve the username from the input field const username = document.getElementById('username').value; try { // Get login options from your server. Here, we also receive the challenge. const response = await fetch('/api/passkey/loginStart', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: username}) }); // Check if the login options are ok. if (!response.ok) { throw new Error('Failed to get login options from server'); } // Convert the login options to JSON. const options = await response.json(); console.log(options) // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new assertionResponse is created. This also means that the challenge has been signed. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Send assertionResponse back to server for verification. const verificationResponse = await fetch('/api/passkey/loginFinish', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(assertionResponse) }); if (verificationResponse.ok) { showMessage('Login successful'); } else { showMessage('Login failed', true); } } catch (error) { showMessage('Error: ' + error.message, true); } }

In script.js, there are three primary functions:

1. showMessage Function:

This is a utility function used primarily for displaying error messages, aiding in debugging.

2. Register Function:

Triggered when the user clicks "Register". It extracts the username from the input field and sends it to the passkeyRegisterStart endpoint. The response includes PublicKeyCredentialCreationOptions, which are converted to JSON and passed to SimpleWebAuthnBrowser.startRegistration. This call activates the device authenticator (like Face ID or Touch ID). Upon successful local authentication, the signed challenge is sent back to the passkeyRegisterFinish endpoint, completing the passkey creation process.

During the register (sign-up) process, the attestation object plays a crucial role, so let's take a closer look at it.

Passkey Attestation Object

The attestation object primarily consists of three components: fmt, attStmt, and authData. The fmt element signifies the format of the attestation statement, while attStmt represents the actual attestation statement itself. In scenarios where attestation is deemed unnecessary, the fmt will be designated as "none," leading to an empty attStmt.

The focus is on the authData segment within this structure. This segment is key for retrieving essential elements such as the relying party ID, flags, counter and attested credential data on our server. Regarding the flags, of particular interest are BS (Backup State) and BE (Backup Eligibility) which provide more information if a passkey is synced (e.g. via iCloud Keychain or 1Password). Besides, UV (User Verification) and UP (User Presence) provide more useful information.

Passkey Authenticator Data

It's important to note that various parts of the attestation object, including the authenticator data, the relying party ID, and the attestation statement, are either hashed or digitally signed by the authenticator using its private key. This process is integral to maintaining the attestation object's overall integrity.

3. Login Function:

Activated when the user clicks "Login". Similar to the register function, it extracts the username and sends it to the passkeyLoginStart endpoint. The response, containing PublicKeyCredentialRequestOptions, is converted to JSON and used with SimpleWebAuthnBrowser.startAuthentication. This triggers local authentication on the device. The signed challenge is then sent back to the passkeyLoginFinish` endpoint. A successful response from this endpoint indicates the user has logged into the app successfully.

Additionally, the accompanying CSS file provides simple styling for the application:

body { font-family: 'Helvetica Neue', Arial, sans-serif; text-align: center; padding: 40px; background-color: #f3f4f6; color: #333; } .container { max-width: 400px; margin: auto; background: white; padding: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); border-radius: 8px; } h1 { color: #007bff; font-size: 24px; margin-bottom: 20px; } .input-group { margin-bottom: 20px; } input[type="text"] { padding: 10px; margin-bottom: 10px; border: 1px solid #ced4da; border-radius: 4px; width: calc(100% - 22px); } button { width: calc(50% - 20px); padding: 10px 0; margin: 5px; font-size: 16px; cursor: pointer; border: none; border-radius: 4px; background-color: #007bff; color: white; } button:hover { background-color: #0056b3; } #message { color: #dc3545; margin: 20px; }

7. Run the Passkey Example App

To see your application in action, compile and run your TypeScript code with:

npm run dev

Your server should now be up and running at http://localhost:8080.

Considerations for Production:

Remember, what we've covered a basic outline. When deploying a passkey application in a production environment, you need to delve deeper into:

  • Security Measures: Implement robust security practices to protect user data.
  • Error Handling: Ensure your application gracefully handles and logs errors.
  • Database Management: Optimize database operations for scalability and reliability.

8. Passkey DevOps Integration

We've already set up a Docker container for our database. Next, we'll expand our Docker Compose setup to include the server with both backend and frontend. Your docker-compose.yml file should be updated accordingly.

To containerize our application, we create a new Dockerfile which installs the required packages and starts the development server:

Docker
# Use an official Node runtime as a parent image FROM node:20-alpine # Set the working directory in the container WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ # Install any needed packages RUN npm install # Bundle your app's source code inside the Docker image COPY . . # Make port 8080 available to the world outside this container EXPOSE 8080 # Define the command to run your app CMD ["npm", "run", "dev"]

Then, we also extend the `docker-compose.yml file to start this container:

docker-compose.yml
version: '3.1' services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db

If you now run docker compose up in your terminal and access http://localhost:8080, you should see the working version of your passkey web app (here running on Windows 11 23H2 + Chrome 119):

Passkey Tutorial Windows Hello

9. Additional Passkey Tips for Developers

Since we've been working for quite some time with passkeys implementations, we encountered a couple of challenges if you work on real-life passkey apps:

  • Device / platform compatibility and support
  • User onboarding and education
  • Handling lost or changed devices
  • Cross-platform authentication
  • Fallback mechanisms
  • Encoding complexity: Encoding is often the hardest part as you have to deal with JSON, CBOR, uint8arrays, buffers, blobs, different databases, base64 and base64url where a lot of errors can occurs
  • Passkey management (e.g. to add, delete or rename passkeys)

Moreover, we have the following tips for developers when it comes to the implementation part:

Utilize Passkeys Debugger

The Passkeys debugger helps to test different WebAuthn server settings and client responses. Moreover, it provides a great parser for authenticator responses.

Debug with Chrome Device Log Feature

Use Chrome's device log (accessible via chrome://device- log/) to monitor FIDO/WebAuthn calls. This feature provides real-time logs of the authentication (login) process, allowing you to see the data being exchanged and troubleshoot any issues that arise.

Another very useful shortcut to get all your passkeys in Chrome is to use chrome://settings/passkeys.

Use the Chrome Virtual WebAuthn Authenticator

To avoid using the Touch ID, Face ID or Windows Hello prompt during development, Chrome comes with a very handy virtual WebAuthn authenticator that emulates a real authenticator. We highly recommend to use it to speed up things. Find more details here.

Test Across Different Platforms and Browsers

Ensure compatibility and functionality across various browsers and platforms. WebAuthn behaves differently on different browsers, so thorough testing is key.

Test on Different Devices

Here it is especially useful to work with tools like ngrok, where you can make your local application reachable on other (mobile) devices.

Set User Verification to Preferred

When defining the properties for userVerification in the PublicKeyCredentialRequestOptions, choose to set them to preferred as this is a good trade-off between usability and security. This means that security checks are in place on suitable devices while user-friendliness is kept on devices without biometric capabilities.

10. Conclusion: Passkey Tutorial

We hope this passkeys tutorial provides a clear understanding of how to implement passkeys effectively. Throughout the tutorial, we've walked through the essential steps to create a passkey application, focusing on fundamental concepts and practical implementation. While this guide serves as a starting point, there's much more to explore and refine in the world of WebAuthn.

We encourage developers to dive deeper into the nuances of passkeys (e.g. adding multiple passkeys, checking for passkey-readiness on devices or offering recovery solutions). It's a journey worth embarking on, offering both challenges and immense rewards in enhancing user authentication. With passkeys, you're not just building a feature; you're contributing to a more secure and user-friendly digital world.

To stay up-to-date in the world of passkeys and get more implementation help and tutorials, join our passkeys community on Slack or subscribe to our passkeys Substack.

Share this article


LinkedInTwitterFacebook

Table of Contents

Enjoyed this read?

🤝 Join our Passkeys Community

Share passkeys implementation tips and get support to free the world from passwords.

🚀 Subscribe to Substack

Get the latest news, strategies, and insights about passkeys sent straight to your inbox.


We provide UI components, SDKs and guides to help you add passkeys to your app in <1 hour

Start for free