supabase-passkeysPasskeys Implementation

Supabase Passkeys: Add Passkeys for Your Supabase Users

Supabase + Passkeys = Supapasskeys: this tutorial explains how to integrate passkeys with Supabase for secure and simple auth solutions.

Blog-Post-Author

Nicolai

Created: July 13, 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

In this tutorial, we will create a simple Node.js app based on Supabase with Corbado as an authentication provider to provide passkeys authentication ("Supapasskeys"). We will pay special attention on how to integrate password- based users of an existing Supabase architecture.

Overview#

We use a simple Node.js app in our backend and plain HTML for the frontend. The app has a login screen as well as a profile screen where information of the current user is displayed. Also, we will use the open-source Firebase alternative Supabase to store our users as well as their data.

With Supabase, developers can build scalable and secure web and mobile applications, leveraging its robust features like data storage, real-time updates, and user authentication.

The flow of information looks like this: the Corbado web component which is integrated into the login page handles all means of authentication and talks with the Node.js backend to make sure existing Supabase users can still login with their password as fallback and are slowly transitioned to passkeys, while new users are offered passkeys during sign-up.

Supabase Passkeys: Flow of Information with Corbado and Supabase

The structure of this tutorial is as follows:

The final code can be found on GitHub. If you want to run it straight away, make sure you have gone through steps 1-3 as you need to set up a Supabase project (step 1) as well as a Corbado project (step 2) and provide the environment variables (step 3) to the application. Afterwards, start the project by running

docker compose up

You can now visit http://localhost:19915 to test the app yourself:

Supabase Passkeys: Corbado Login Page

Now back to the implementation details - the code of our project is structured as follows:

├── 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 Supabase | ├── views/pages | | ├── login.ejs # Login page with the webcomponent | | └── profile.ejs # Profile page showing user info

1. Set Up Supabase Backend

For this step, we head over to Supabase and create an account. Afterwards, we create a new project. Select name, password and region according to your preferences.

Supabase Passkeys: Create new Project in Supabase

Then, we click on Create new project which makes Supabase instantiate our project. Beware this can take some time (~5-10 seconds during our test).

As we use Supabases user infrastructure for storing users, we do NOT need to create a user table inside Supabase.

Under Authentication > Users, well now add some password-based users, by clicking on Add user.

Supabase Passkeys: Create

Come up with an email and password and click on create user. Here, we used "max@company.com" as email and maxPW as password. Make sure to auto confirm the user so it is a full-fledged account. Only then are we able to login as this user using the Supabase JavaScript client. We will also use the JavaScript client to access information stored in Supabases tables.

Supabase Passkeys: Create

To later integrate these existing Supabase users into our app, we need to be able to tell if a user already exists in Supabase based on the provided email address. The Supabase JavaScript client does not provide a function to get a user by his email, so we must resort to something else. Well use the Supabase rpc functions, which are custom functions that can be defined inside the Supabase web interface and subsequently called via the Supabase JavaScript client.

Inside the Supabase web interface, we use the SQL Editor to create a new function get_user_id_by_email by executing the following PostgreSQL query:

CREATE OR REPLACE FUNCTION get_user_id_by_email(email TEXT) RETURNS TABLE (id uuid) SECURITY definer AS $$ BEGIN RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1; END; $$ LANGUAGE plpgsql;

This function returns the ID of a user for a given email (assuming the user exists in Supabase).

Supabase Passkeys: SQL Function to return ID of a User for given email

Now, we are done with the Supabase part of our app. The necessary credentials for communication with the Supabase JavaScript client can be obtained under "Settings > API". We will put the Project URL, the service_role API key and the JWT Secret into our .env file in step 3. The service_role key authorizes us as an administrator of the corresponding project, thus enabling additional API calls.

Supabase Passkeys: Enable additional API calls in Supabase

Supabase Icon

Add passkey authentication to your Supabase users.

Start For Free

2. Configure Corbado project

Before building our frontend, we need to create a Corbado project. We start by heading over to the Corbado developer panel and create an account. After successful sign-up, we see this screen:

Supabase Passkeys: Corbado Developer Panel create Project

We click on Integration guide to do everything step by step:

Supabase Passkeys: Corbado Developer Panel Integration Guide

We want to integrate via Web component, so thats what we select.

Supabase Passkeys: Corbado Developer Panel Web Componente

We have a system with existing users, so we click Yes. Afterwards, we find ourselves at the overview of the developer panel (you need to confirm your account via email if its your first time).

Well head over to Getting started >Integration guide. This guides us through all the steps necessary for the integration to work.

Supabase Passkeys: Corbado Developer Panel create Project

Remember to select Node.js as progamming language in the top right corner.

In step 1 of the integration guide, we configure our authorized origin. The authorized origin is the browser URL of the site where the web component is integrated (with protocol and port but without path). We need it for CORS (cross-origin request sharing). In our case, we set it to http://localhost:19915. Next, we create an API secret. We need the project ID and the generated API secret later in order to communicate with Corbados Backend API.

Supabase Passkeys: Corbado Developer Panel Configure Authentication

In the second, optional step, we configure the webhook. Webhooks are needed, so Corbado can communicate with our backend / Supabase, e.g. for checking if a username and password of an existing user match. More details on that later.

We will later set up our webhook at http://localhost:19915/corbado-webhook with webhookUsername and webhookPassword as credentials, so we can already enter that here.

Supabase Passkeys: Corbado Developer Panel create Webhooks

In step 3, we add our Application URL, Redirect URL and Relying Party ID. The Application URL is the URL in the frontend where the web component runs. For example, its used to forward users to the web component again after they clicked on an email magic link.

The Redirect URL is the URL where the user is directed to once the authentication has succeeded. In our case, this will be the /profile page, so we can enter http://localhost:19915/profile here already.

The Relying Party ID is the domain where we bind our passkeys to. The domain in the browser where a passkey is bount to must be a matching domain to the Relying Party ID. As we test locally, we set the Relying Party ID to localhost.

Supabase Passkeys: Define URLs and Relying Party ID

Just like that, the project in Corbado is set up!

3. Configure Environment Variables

We use a simple Node.js express app to deliver our plain HTML frontend. For this to work, the following Node.js environment variables should be configured in the respective .env file.

Supabase variables can be taken from step 1. PROJECT_ID, API_SECRET as well as CLI_SECRET should be taken from step 2.

// .env PROJECT_ID="" API_SECRET="" CLI_SECRET="" WEBHOOK_USERNAME="webhookUsername" WEBHOOK_PASSWORD="webhookPassword" SUPABASE_URL="" SUPABASE_API_KEY_SERVICE_ROLE="" SUPABASE_JWT_SECRET=""

4. Integrate the Corbado Web Component

4.1. Create HTML Frontend Delivered by Node.js

Inside our Node.js app, we have two screens:

  • Login screen
  • User profile screen showing information when a user is logged in

Our login screen contains only the Corbado web component which will handle the authentication. For more details on its usage, visit the web component docs.

// src/views/pages/login.ejs <!DOCTYPE html> <html> <body> <!-- Your website content --> <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 project-id="<%= process.env.PROJECT_ID %>" conditional="yes"> <input name="username" id="corbado-username" value="" data-input="username" required autocomplete="webauthn" /> </corbado-auth> </body> </html>

4.2. Set Up Profile Page

Once a user has authenticated, Corbado will create a session and redirect the user to the Redirect URL we defined beforehand (our http://localhost:19915/profile page).

We created a profile.ejs template that just displays some basic info about the user as well as a logout button:

// src/views/pages/profile.ejs <!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> <corbado-auth-provider project-id="<%= process.env.PROJECT_ID %>"> <div slot="authed"> <div class="main-container"> <h1>Welcome!</h1> <p>Email: <%= username %><br> Name: <%= userFullName %><br> SupabaseID: <%= supabaseID %><br> </p> </div> <corbado-logout-handler project-id="<%= process.env.PROJECT_ID %>" redirect-url="/logout"> <button>Logout</button> </corbado-logout-handler> </div> </corbado-auth-provider> </body> </html>

4.3. Add Corbado Session Management

Here we need the Corbado SDK, which we can install by executing

npm install @corbado/node-sdk

Before delivering the profile page, the backend uses the Corbado SDK to retrieve the user of the currently active session.

Then, we add the user to our Supabase database if it doesnt exist there yet. As an intermediate between our profile-page endpoint and Supabase, we created the UserService which will be explained in a minute.

Once we have got our Supabase user, we render the page displaying the Supabase user ID, email and name.

// src/controllers/authController.js export const profile = async (req, res) => { try { const { email, name } = await corbado.session.getCurrentUser(req); const user = await UserService.findByEmail(email); const userId = user?.id; if (!userId) { // Create new user UserService.create(email, name).then((u) => { if (u == null) { res.redirect("/logout"); } else { const user = u.user; res.render("pages/profile", { username: user.email, userFullName: user.user_metadata.name, supabaseID: user.id, }); } }); } else { // User already exists res.render("pages/profile", { username: user.email, userFullName: user.user_metadata.name, supabaseID: user.id, }); } } catch (err) { console.error(err); res.status(500).send("Server Error"); } };
Substack Icon

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

Subscribe

5. Connect Passkeys App to Supabase

As mentioned before, we created the UserService to handle all actions concerning the Supabase JavaScript client. But first, we need to install the Supabase JavaScript client:

npm install@supabase/supabase-js

We initialize the client with the Supabase role key as parameter because we will use the auth.admin calls to manage our users and the role key authorizes us as the admin.

Remember to never expose your Supabase role key!

The UserService contains methods which handle the following processes:

  1. create a new user
  2. verify the passwords of an existing user
  3. retrieve a user, given the user ID
  4. retrieve a user ID, given an email address

Apart from get_user_id_by_email, we only use predefined methods of the Supabase JavaScript client which uses internally the Supabase user infrastructure. All users are hereby stored in the auth.users table which can be viewed in the Supabase dashboard.

// src/services/userService.js import { createClient } from "@supabase/supabase-js"; import { config as dotenvConfig } from "dotenv"; dotenvConfig(); const supabaseUrl = process.env.SUPABASE_URL; const supabaseRoleKey = process.env.SUPABASE_API_KEY_SERVICE_ROLE; const supabase = createClient(supabaseUrl, supabaseRoleKey, { auth: { autoRefreshToken: false, persistSession: false, }, }); export const create = async (username, userFullName) => { const { data, error } = await supabase.auth.admin.createUser({ email: username, user_metadata: { name: userFullName, isCorbadoUser: true }, email_confirm: true, }); if (error) { console.log("Error from create user: ", error.message); return null; } console.log(data); return data; }; export const findByEmail = async (email) => { var { data, error } = await supabase.rpc("get_user_id_by_email", { email: email, }); if (error) { console.log("Error from get_user_id_by_email: ", error.message); return null; } console.log(data); if (data.length == 0) { // No user found return null; } const id = data[0].id; var { data, error } = await supabase.auth.admin.getUserById(id); if (error) { console.log("Error from getUserById: ", error.message); return null; } console.log(data); if (data.user == null) { // No user found return null; } return data.user; }; export const verifyPassword = async (email, password) => { const { data, error } = await supabase.auth.signInWithPassword({ email: email, password: password, }); if (error) { console.log("Error from verifyPassword: ", error.message); return null; } console.log(data); return data; };

6. Integrate Existing Users

6.1. Add Webhooks

In step 1, we added password-based users to our Supabase userbase. To let these existing users still login with their passwords, we set up webhooks.

Beforehand, we set up in the Corbado developer panel that the webhook we provide would be reachable via http://localhost:19915/corbado-webhook, so we create a controller for the route which handles Corbados webhooks.

There, we initialize the Corbado Node.js SDK using the project ID and API Secret from step 2. The webhook method below can be taken as a communication template for the Corbado webhooks.

The only methods you would have to change here are "getUserStatus" and "verifyPassword". Inside these two methods, we again use our UserService to check if a certain user exists and if a password matches for a certain user.

// src/controllers/corbadoWebhookController.js 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); async function getUserStatus(username) { const user = await UserService.findByEmail(username); const isCorbadoUser = user != null && user.user_metadata.isCorbadoUser; if (!user || isCorbadoUser) { return "not_exists"; } else { return "exists"; } } 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; } } export const webhook = async (req, res) => { try { // Get the webhook action and act accordingly. Every CorbadoSDK // webhook has an action. let request; let response; switch (corbado.webhooks.getAction(req)) { // Handle the "authMethods" action which basically checks // if a user exists on your side/in your database. case corbado.webhooks.WEBHOOK_ACTION.AUTH_METHODS: { request = corbado.webhooks.getAuthMethodsRequest(req); // Now check if the given user/username exists in your // database and send status. Implement getUserStatus() // function below. const status = await getUserStatus(request.data.username); response = corbado.webhooks.getAuthMethodsResponse(status); res.json(response); break; } // Handle the "passwordVerify" action which basically checks // if the given username and password are valid. case corbado.webhooks.WEBHOOK_ACTION.PASSWORD_VERIFY: { request = corbado.webhooks.getPasswordVerifyRequest(req); // Now check if the given username and password is // valid. Implement verifyPassword() function below. 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) { // We expose the full error message here. Usually you would // not do this (security!) but in this case CorbadoSDK is the // only consumer of your webhook. The error message gets // logged at CorbadoSDK and helps you and us debugging your // webhook. console.log(error); // If something went wrong just return HTTP status // code 500. For successful requests CorbadoSDK always // expects HTTP status code 200. Everything else // will be treated as error. return res.status(500).send(error.message); } };

6.2. Connect Local Instance to the Internet

Corbado will attempt to call our webhooks, but currently they are hosted locally on http://localhost:19915/corbado-webhook, so we need to make them publicly available. We do so by using the Corbado CLI. It creates a tunnel between Corbado and our local instance, so Corbado can send webhooks to our local instance. Install it as described in our Corbado CLI docs. Then execute

corbado login

This will prompt you for your project ID from step 2 as well as your CLI secret which you can find in our app.

Afterwards execute

corbado subscribe http://localhost:19915

This will start the tunnel on port 19915.

7. Run the Supabase Passkeys Application

With

npm start

you should be able to run the application now. When visiting http://localhost:19915 you should see the Corbado web component:

Supabase Passkeys: Corbado Login Page

Supabase Passkeys: Corbado Profile Page

If we login as the password-based user we created in step 1 using our password, we are offered to create a passkey after successful password authentication:

Supabase Passkeys: Create new Passkey

Slack Icon

Become part of our Passkeys Community for updates and support.

Join

8. Next Steps

We can now create tables in Supabase which have a foreign key linking to the ID of the auth.users table. Remember to configure Row Level Security (RLS) before querying.

To enforce RLS, we can then act on behalf of a specific user when using the Supabase JavaScript client. Therefore, the RLS policies will only let us perform actions that the respective user should be allowed to do. Use the following snippet to create a Supabase JavaScript client which identifies itself as a specific Supabase user whose ID is stored in userID.

This should only be done in your backend. Remember to never publicly expose your Supabase JWT secret!

const getSupabaseClient = (userID) => { const payload = { userID, sub: userID, exp: Math.floor(Date.now() / 1000) + 60 * 60, }; const token = jwt.sign(payload, process.env.SUPABASE_JWT_SECRET); const supabase = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_API_KEY, { global: { headers: { Authorization: `Bearer ${token}`, }, }, } ); return supabase; };

9. Troubleshooting

You must remember to set up Row Level Security for tables you create in Supabase, otherwise you cannot perform any actions.

If the session initialization does not work, please make sure you have Corbado session management enabled in the developer panel under Settings > Sessions.

Supabase Passkeys: Corbado Session Management

10. Conclusion: Passkeys in Supabase

This tutorial showed how easy it is to add passkey authentication to a Node.js application which uses Supabase as a database provider and already has an existing user base (with passwords).

Authentication as well as session management is handled by Corbado all while we manage the users ourselves and can save user-data tied to our Supabase users instead of using a user identifier from Corbado. This means we are still independent and in control of our users and their data. If you want to read more about how you can leverage the session management to retrieve backend data, please see our Corbado docs.

Due to clear documentation and a well-structured client, Supabase is very convenient and easy to use. Although one must watch out which API keys and JWT secrets to use where and which of them to keep secret.

Integrating other authentication providers is not as easy as it could be as Supabase is optimized towards their own password-based authentication and session management solution. In general, for some features like passkeys you need external solutions because Supabase does not offer an implementation itself (yet).

Supabase also has a limited predefined interface which is why a custom rpc function was needed for mapping emails to user IDs.

Although one can create a user via the admin auth API without requiring a password, the entry in the auth.users table in Supabase for that new user will contain a hash in the password column which leads to the assumption that a random password might be generated nevertheless when creating a user without specifying a password.

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