This tutorial shows how to add passkeys to an app that has existing Keycloak users. Corbado's passkey-first web component is used for better passkey experience.
Nicolai
Created: November 23, 2023
Updated: February 17, 2025
We're currently reworking the Corbado Connect approach. Reach out if you want to get early access.
Get early access2. Keycloak Passkey Project Prerequisites
3. Overview: Keycloak Passkey Project
4. Set Up Your Corbado Account and Project
6.1. Configure Environment Variables
6.2. Create User Service Utilizing Keycloak's API
6.3. Create Passkey Login Page
7. Enable Password-Based Login as Fallback
8. Make Your Local Application Reachable for Corbado via Corbado CLI
In this blog post, we'll be walking through the process of adding passkey authentication to an app which already has an existing Keycloak user base. Since Keycloak's passkey implementation and flow isn't very user-friendly and quite difficult to set up, we use Corbado's passkey-first web component that automatically connects to a hosted passkeys backend. To keep all existing accounts and allow these to use passwords as fallback, we connect Corbado to the existing userbase via webhooks. For maximum flexibility and to touch as little as possible in the running system, we still use Keycloak as the main solution for user management (also new user who sign up with a passkey will be stored in Keycloak).
If you want to see the finished code, please have a look at our sample application GitHub repository.
The result looks as follows:
Recent Articles
⚙️
FastAPI Passkeys: How to Implement Passkeys with FastAPI
⚙️
Passkey Tutorial: How to Implement Passkeys in Web Apps
⚙️
Django Passkeys: How to Implement Passkeys with Python Django
⚙️
Laravel Passkeys: How to Implement Passkeys in PHP Laravel
⚙️
Cloudflare Passkeys: Deploying a React Passkey App on Cloudflare Pages
This tutorial assumes basic familiarity with HTML, JavaScript (in particular Node.js / Express), Docker and Keycloak. Lets dive in!
Let's get the whole picture first! We use a dockerized setup with two containers:
Our folder structure looks like this:
├── app.js ├── .env ├── src | ├── controllers | | ├── authController.js # renders views and uses Corbado SDK for sessions | | └── corbadoWebhookController.js # Takes all requests belonging to the Corbado webhook logic | | | ├── routes | | ├── authRoutes.js # All routes belonging to certain views | | └── corbadoWebhookRoutes.js # All routes belonging to the Corbado webhook | | | ├── services | | └── userService.js # Communicates with Keycloak | | | └── views/pages | ├── login.ejs # Login page with the UI Components | └── profile.ejs # Profile page showing user info
If you want to run the example right away, you can follow the Readme of the repository. This tutorial focuses on the technical implementation details.
Visit the Corbado developer panel to sign up and create your account (youll see passkey sign-up in action here!).
In the appearing project wizard, select Web app as type of app and afterward select Yes (My app already has users) as we already got password-based users. Moreover, providing some details regarding your frontend and backend tech stack as well as the main goal you want to achieve with Corbado helps us to customize and smoothen your developer experience.
Next, we navigate to Settings > General > URLs and set the Application URL, Redirect URL and Relying Party ID to the following values (We will host our app on port 3000):
Finally go to the credentials settings and create an API Secret by clicking on "Add new".
We will use Keycloak's Docker image to create a container which we provide with initial admin credentials. See this Keycloak guide if you want to know more about running Keycloak with Docker. Use the following command to initialize the Keycloak Docker container:
docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:22.0.5 start-dev
If you now visit localhost:8080/admin you should see Keycloaks admin console.
Use admin as username and also admin as password to login to the site. Switch to Users and click on Add user.
Come up with sample user details, turn on Email verified and click Create.
Switch to the "Credentials" tab and click "Set password".
Select a password, make sure to turn off "Temporary" and press "Save".
We now have got a password-based user in our app. Feel free to create more users with passwords.
For this project, we will use a simple Express app. First create an app.js file with the following code:
import express from "express"; import ejs from "ejs"; import authRoutes from "./src/routes/authRoutes.js"; import webhookRoutes from "./src/routes/corbadoWebhookRoutes.js"; import cookieParser from "cookie-parser"; import bodyParser from "body-parser"; dotenvConfig(); const app = express(); app.use(cookieParser()); app.use(bodyParser.json()); app.set("views", "./src/views"); app.set("view engine", "ejs"); const PORT = 3000; app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Note: We'll create the views in the next steps, but we already set the folder here.
Next, initialize your app with
npm init
Select a name you like and put app.js as entry point. Now we need to install Express:
npm install express --save
That's it concerning the core of our app. We will now go on to configure the environment variables and then create the pages we need.
We will need the Corbado project ID and API secret in the next steps, so well put it into our environment variables. For this, we create a .env file with the contents of .env.example and paste our values:
PROJECT_ID="" API_SECRET="" CLI_SECRET="" KEYCLOAK_REALM_NAME="master" KEYCLOAK_ADMIN="admin" KEYCLOAK_ADMIN_PASSWORD="admin" KEYCLOAK_BASE_URL="http://keycloak:8080"
To load the environment variables, we will use dotenv. Install it with
npm i dotenv
Here, we will need Keycloak's Admin API. Install the corresponding client via npm:
npm i @keycloak/keycloak-admin-client
We now create the file services/UserService.js and inside initialize the Keycloak API client.
import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; import { config as dotenvConfig } from "dotenv"; dotenvConfig(); const keycloakRealmName = process.env.KEYCLOAK_REALM_NAME; const keycloakAdminUsername = process.env.KEYCLOAK_ADMIN; const keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD; const keycloakBaseUrl = process.env.KEYCLOAK_BASE_URL; const kcAdminClient = new KeycloakAdminClient({ realmName: keycloakRealmName, baseUrl: keycloakBaseUrl, }); const adminAuth = async () => { await kcAdminClient.auth({ username: keycloakAdminUsername, password: keycloakAdminPassword, grantType: "password", clientId: "admin-cli", }); };
For our communication with Keycloak concerning our existing users, we need to provide our app with four methods:
For each of these tasks, we use a dedicated method of the admin API client and extract the data we want from the methods returned values:
export const create = async (username, userFullName) => { var firstName = userFullName; var lastName = ""; if (userFullName.includes(" ")) { const split = userFullName.split(" "); firstName = split[0]; lastName = userFullName.replace(firstName, "").trim(); } await adminAuth(); const res = await kcAdminClient.users.create({ realm: keycloakRealmName, username: username, email: username, enabled: true, firstName: firstName, lastName: lastName, emailVerified: true, attributes: { isCorbadoUser: true, }, }); return res.id; }; export const findByEmail = async (email) => { await adminAuth(); const users = await kcAdminClient.users.findOne({ email: email }); if (users.length == 0 || users[0].email != email) { return null; } return users[0]; }; export const findById = async (userId) => { await adminAuth(); const user = await kcAdminClient.users.findOne({ id: userId }); return user; }; export const verifyPassword = async (name, password) => { try { const res = await kcAdminClient.auth({ username: name, password: password, grantType: "password", clientId: "admin-cli", }); return true; } catch (error) { console.log(error); return false; } };
Now to the login page: We first create a template in views/pages/login.ejs and paste the following HTML content:
<!DOCTYPE html> <html> <body> <style> corbado-auth { --primary-color: #1953ff; --primary-color-rgb: 25, 83, 255; --heading-color: #090f1f; --text-color: #535e80; --light-color: #8f9bbf; --error-color: #FF4C51; --primary-font: 'Space Grotesk', sans-serif; --secondary-font: 'Inter', sans-serif; --border-color: rgba(143, 155, 191, 0.5); } </style> <script defer src="https://<%= process.env.PROJECT_ID %>.frontendapi.corbado.io/auth.js"></script> <corbado-auth-provider project-id="<%= process.env.PROJECT_ID %>"> <corbado-auth project-id="<%= process.env.PROJECT_ID %>" conditional="yes"> <input name="username" id="corbado-username" value="" data-input="username" required autocomplete="webauthn"/> </corbado-auth> </corbado-auth-provider> </body> </html>
It contains the Corbado web component as well as a Corbado script which is required for the web component to work.
Next, we need a controller which renders this ejs file when called. Create a file controllers/authController.js with the following methods:
import * as UserService from "../services/userService.js"; import { SDK, Configuration } from "@corbado/node-sdk"; const projectID = process.env.PROJECT_ID; const apiSecret = process.env.API_SECRET; const config = new Configuration(projectID, apiSecret); const corbado = new SDK(config); export const home = (req, res) => { res.redirect("/login"); }; export const login = (req, res) => { res.render("pages/login"); }; export const logout = (req, res) => { res.redirect("/");
Note: The Corbado project ID and API secret are taken from the environment variables.
The routes are still missing. To keep everything clean, well put our routes in a separate file under routes/authRoutes.js connecting specific routes with the controller methods we just created.
import express from "express"; import { home, login, profile, logout } from "../controllers/authController.js"; const router = express.Router(); // home page router.get("/", home); // login page router.get("/login", login); // logout page router.get("/logout", logout); export default router;
As a last step, we modify the core of our app (app.js) to use these routes:
import authRoutes from "./src/routes/authRoutes.js"; app.use("/", authRoutes);
After successful authentication, the Corbado web component redirects the user to the provided Redirect URL (https://localhost:3000/profile). This page displays information about the user and provides a button to log out. In the views/pages folder add a profile.ejs template with the following content:
<!DOCTYPE html> <html> <head> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous"> <style> body { padding: 2em; } button { border-radius: 5px; padding: 3px; } </style> </head> <body> <script src="https://<%= process.env.PROJECT_ID %>.frontendapi.corbado.io/auth.js"></script> <div slot="authed"> <div class="main-container"> <h1>Welcome!</h1> <p>Email: <%= username %><br> Name: <%= userFullName %><br> KeycloakID: <%= keycloakID %><br> </p> </div> <corbado-logout-handler project-id="<%= process.env.PROJECT_ID %>" redirect-url="/logout"> <button>Logout</button> </corbado-logout-handler> </div> </body> </html>
In our next step, we will need the Corbado Node.js SDK to get the current user. Make sure to install it with
npm i @corbado/node-sdk
Next create a profile() method inside controllers/authController.js. As mentioned, we use the Corbado Node.js SDK together with the UserService we created in step 6.2.
export const profile = async (req, res) => { try { const { email, name } = await corbado.session.getCurrentUser(req); if (!email) { res.redirect("/logout"); } const user = await UserService.findByEmail(email); if (!user) { // Create new user UserService.create(email, name).then((u) => { if (u == null) { res.redirect("/logout"); } else { UserService.findById(u).then((userNew) => { res.render("pages/profile", { username: userNew.email, userFullName: userNew.firstName, keycloakID: userNew.id, }); }); } }); } else { // User already exists res.render("pages/profile", { username: user.email, userFullName: user.firstName, keycloakID: user.id, }); } } catch (err) { console.error(err); res.status(500).send("Server Error"); } };
Also we need a "/profile" route in our routes/authRoutes.js file:
// user profile page router.get("/profile", profile);
We don't want to lock out our existing users, so we enable password-based authentication as a fallback. Therefore, Corbado needs to communicate with our backend. It does so via a webhook that we will set up in our app. In the steps before, we set the webhook URL to http://localhost:3000/corbado-webhook as well as the webhook username and password. The Corbado Node.js SDK from earlier also provides code that helps to add the webhooks.
We add the username and password of the webhook to our environment variables, so that they are available when we want to authenticate an incoming webhook call.
PROJECT_ID="" API_SECRET="" CLI_SECRET="" WEBHOOK_USERNAME="webhookUsername" WEBHOOK_PASSWORD="webhookPassword" KEYCLOAK_REALM_NAME="master" KEYCLOAK_ADMIN="admin" KEYCLOAK_ADMIN_PASSWORD="admin" KEYCLOAK_BASE_URL="http://keycloak:8080"
Next, we create a controller for our webhooks under controllers/corbadoWebhookController.js which makes use of our UserService:
import * as UserService from "../services/userService.js"; import { Configuration, SDK } from "@corbado/node-sdk"; const projectID = process.env.PROJECT_ID; const apiSecret = process.env.API_SECRET; const config = new Configuration(projectID, apiSecret); const corbado = new SDK(config); export const webhook = async (req, res) => { try { let request; let response; switch (corbado.webhooks.getAction(req)) { case corbado.webhooks.WEBHOOK_ACTION.AUTH_METHODS: { request = corbado.webhooks.getAuthMethodsRequest(req); const status = await getUserStatus(request.data.username); response = corbado.webhooks.getAuthMethodsResponse(status); res.json(response); break; } case corbado.webhooks.WEBHOOK_ACTION.PASSWORD_VERIFY: { request = corbado.webhooks.getPasswordVerifyRequest(req); const isValid = await verifyPassword( request.data.username, request.data.password ); response = corbado.webhooks.getPasswordVerifyResponse(isValid); res.json(response); break; } default: { return res.status(400).send("Bad Request"); } } } catch (error) { console.log(error); return res.status(500).send(error.message); } };
If a user enters their email in the web component, the webhook is triggered. The webhook function will handle it, resulting in a call of the userStatus function. If the user exists, meaning he already has a password, Corbado gives him the option to login via password.
async function getUserStatus(username) { const user = await UserService.findByEmail(username); if (!user) { return "not_exists"; } const isCorbadoUser = user.attributes && user.attributes.isCorbadoUser && user.attributes.isCorbadoUser[0] === "true"; if (isCorbadoUser) { return "not_exists"; } else { return "exists"; } }
Once the user has entered his password, another webhook call is issued resulting in a call to the verifyPassword function. There we check our database if the given credentials match and send the response back.
async function verifyPassword(username, password) { try { const res = await UserService.verifyPassword(username, password); if (!res) { return false; } return true; } catch (error) { console.log(error); return false; } }
Now, we create a route for the webhook controller in a new file called routes/corbadoWebhookRoutes.js:
import express from 'express'; import { webhook as webhookController } from '../controllers/corbadoWebhookController.js'; import {SDK, Configuration} from '@corbado/node-sdk'; import dotenv from 'dotenv'; dotenv.config(); const projectID = process.env.PROJECT_ID; const apiSecret = process.env.API_SECRET; const config = new Configuration(projectID, apiSecret); config.webhookUsername = process.env.WEBHOOK_USERNAME; config.webhookPassword = process.env.WEBHOOK_PASSWORD; const corbado = new SDK(config); const router = express.Router(); router.post('/corbado-webhook', corbado.webhooks.middleware, webhookController); export default router;
This file then gets added to our application core:
import webhookRoutes from "./src/routes/corbadoWebhookRoutes.js"; app.use("/", webhookRoutes);
But how can the Corbado server call our webhooks if we are testing our implementation locally? Thats what well tackle in the next step.
Using the Corbado CLI we can effortlessly create a tunnel from our local instance to the outside world so that the local webhooks can be called by the remote Corbado server. Follow the docs to install the Corbado CLI. You can either download the binary for your OS, or install it directly via go:
go install github.com/corbado/cli/cmd/corbado@latest
Afterwards, we head to the CLIsettings page of the developer panel to copy our CLI secret. We login using the project ID and CLI secret:
corbado login --projectID pro-xxx --cliSecret corbado1_xxx
Once logged in, we can start our tunnel using the subscribe command (with port 3000 as our Express app is running here).
corbado subscribe http://localhost:3000 --projectID pro-xxx --cliSecret corbado1_xxx
Once started, the webhook running on our machine forwards requests from Corbado to our local instance. The process is then as follows:
Now, we can test if our webhook works by going to the webhooks testing page, filling out the stub data and running the tests. If everything went right, all tests will pass.
In our sample application on Github, we packed everything together using multiple docker containers, so the only thing you need to do is heading into the root directory and creating a .env file with the contents of .env.example (but your values of course) and executing
docker compose up
When visiting http://localhost:3000 you should see the Corbado web component.
In case you're using a password based Keycloak account, you will be asked for your password:
If the password authentication succeeded, you get the option to create a passkey for future logins:
Afterwards you get redirected to the profile page, where some user info is displayed:
This tutorial showed how easy it is to add passwordless authentication with passkeys to a Keycloak-based app using Corbado. With the webhooks template, the integration of existing password based users is as comfortable as it gets. Besides the passkey-first authentication, Corbado provides easy integration for a wide variety of frameworks and languages. If you want to read more about hooking up your existing users to the Corbado system, read our documentation here, or if you want add Corbado to your new project with no existing users, please see our documentation here.
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.