keycloak-passkeysPasskeys Implementation

Keycloak Passkeys: Add Passkeys To Your Existing Keycloak Users

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.

Blog-Post-Author

Nicolai

Created: November 23, 2023

Updated: July 24, 2024


ChangeCorbadoConnect Icon

We're currently reworking the Corbado Connect approach. Reach out if you want to get early access.

Get early access

Overview#

1. Introduction

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:

Corbado webcomponent

2. Keycloak Passkey Project Prerequisites

This tutorial assumes basic familiarity with HTML, JavaScript (in particular Node.js / Express), Docker and Keycloak. Lets dive in!

3. Overview: Keycloak Passkey Project

Let's get the whole picture first! We use a dockerized setup with two containers:

  1. The first container runs our Express application
  2. The second container hosts Keycloak together with all its functionalities and databases

Project Structure

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.

Slack Icon

Become part of our Passkeys Community for updates and support.

Join

4. Set Up Corbado Account and Project

Visit the Corbado developer panel to sign up and create your account (youll see passkey sign-up in action here!).

Corbado developer panel

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):

Corbado developer panel URLs settings

  • Application URL: Provide the URL where you embedded the web component, http://localhost:3000/login
  • Redirect URL: Provide the URL your app should redirect to after successful authentication and which gets sent a short-term session cookie, here: http://localhost:3000/profile
  • Relying Party ID: Provide the domain (no protocol, no port and no path) where passkeys should be bound to, here: localhost

Finally go to the credentials settings and create an API Secret by clicking on "Add new".

Corbado developer panel credentials settings

5. Set Up Keycloak

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.

Keycloak dashboard login

Use admin as username and also admin as password to login to the site. Switch to Users and click on Add user.

Keycloak dashboard users

Come up with sample user details, turn on Email verified and click Create.

Keycloak dashboard create user

Switch to the "Credentials" tab and click "Set password".

Keycloak dashboard user credentials

Select a password, make sure to turn off "Temporary" and press "Save".

Keycloak dashboard set password

We now have got a password-based user in our app. Feel free to create more users with passwords.

6. Create Express App

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.

6.1 Configure Environment Variables

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

6.2 Create User Service Utilizing Keycloak's API

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:

  • Getting a user by email
  • Getting a user by id
  • Verifying a given username and password
  • Creating a new user

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; } };

6.3 Create Passkey Login Page

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);

6.4 Create Profile Page

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);
Substack Icon

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

Subscribe

7. Enable Password-Based Login as Fallback

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.

8. Make Your Local Application Reachable for Corbado via Corbado CLI

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:

Corbado CLI information flow

  1. The browser requests our login page.
  2. The authController sends back the HTML page which contains the web component.
  3. After the user has entered the email address, the web component sends it to Corbado.
  4. Corbado processes it and sends a request to our webhook to check if the user exists.
  5. The message gets forwarded through Corbado CLI in order to reach our local instance. (If your solution is in production, Corbado can directly call the Webhook controller without the need for the Corbado CLI tunnel because you then have a public address.
  6. Our webhook controller sends back the requested information.
  7. The message gets forwarded through Corbado CLI again.
  8. Corbado tells the web component how to react (e.g., ask the user for a password).

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.

Corbado developer panel webhooks testing

9. Start Using Passkeys With Our Keycloak Implementation

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.

Corbado webcomponent

In case you're using a password based Keycloak account, you will be asked for your password:

Corbado web component password login

If the password authentication succeeded, you get the option to create a passkey for future logins:

Blog Post Image

Afterwards you get redirected to the profile page, where some user info is displayed:

Passkey profile page

10. Conclusion

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.

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