This tutorial explains how to implement passkeys in your web app. We use Node.js (TypeScript), SimpleWebAuthn, Vanilla HTML / JavaScript and MySQL.
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.
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
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.4 Credential Service & User Service
5.7 Passkey Controllers with SimpleWebAuthn
6. Integrate Passkeys into the Frontend
7. Run the Passkey Example App
9. Additional Passkey Tips for Developers
10. Conclusion: Passkey Tutorial
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).
Are your users passkey-ready?
Test Passkey-ReadinessBy the end of this journey, you will have built a minimum viable web application, where you can:
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):
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.
Become part of our Passkeys Community for updates and support.
JoinBefore 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:
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.
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.
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
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 CommunityBefore 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:
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):
The following chart describes the process flow during authentication (login):
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
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
, 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.
After we have created this file, we create a new docker-compose.yml
file on
the root level of the project:
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:
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:
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:
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.
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.
First, create a new directory for your project and navigate into it using your terminal or command prompt.
Run the command
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:
Switch into the new directory and install them with the following commands (we also install the required TypeScript types):
To confirm that everything is installed correctly, run
This should start your Node.js server in development mode with Nodemon, which automatically restarts the server upon any file changes.
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:
These enhancements are key to enabling passkey authentication in your application's backend. We set them up later.
Want to experiment with passkey flows? Try our Passkeys Debugger.
Try for FreeAfter 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:
This file will later be used by our server to access the database.
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:
package.json
can stay as is and should look like:
index.ts
looks like:
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):
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:
credentialService.ts
looks as follows:
For handling errors centrally and also making debugging easiert, we add an
errorHandler.ts
file:
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:
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:
utils.ts
holds two functions we later need to encoding and decoding data:
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:
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.name
) and the domain (rp.id
).user.name
, user.id
, and user.displayName
.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:
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; };
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.
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
:
Subscribe to our Passkeys Substack for the latest news, insights and strategies.
SubscribeThis 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.html
looks like this:
script.js
looks as follows:
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.
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.
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:
To see your application in action, compile and run your TypeScript code with:
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:
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:
Then, we also extend the `docker-compose.yml file to start this container:
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):
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:
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.
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.
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