Guide
9 Min.
Read

Passkeys in Solid.js and Bun

If you're looking to enhance your Solid.js app's authentication by adding passkeys, you're in the right place! In this tutorial, we'll guide you through the process of integrating passkey authentication into your existing Solid app.

Passkey demo (passkeys.io)

We'll assume that you already have an authentication system in place, so we'll focus specifically on adding a passkey functionality to complement your current setup.

To bring passkeys to your app, you'll typically need two things:

  1. A backend to handle the authentication flow and store your users' passkey. We'll be using Bun with Hono for the backend, but the process will be pretty similar to any JS based backend.
  2. A few functions in your Solid.js frontend to display and handle the "Sign in with a passkey" and "Register a passkey" dialogs. These functions will interact with your backend to facilitate the passkey authentication process.

So, let's get started on upgrading your app's login experience! To make it easier for you to follow along, we have created a sample app that you can use as a reference throughout the tutorial.

We'll be implementing passkeys in two phases.

  • Passkey Registration: In this phase, we'll set up the necessary things to allow users to register and store their passkey.
  • Sign In with Passkey: Once users have registered their passkeys, we'll implement the functionality to enable them to log in using their registered passkeys.

Passkey Registration

This is what the general flow looks like. There will be two steps ("start" and "finalize"), which pass through the frontend, backend, and Passkey API.

1. Start Registration: Client requests server to start registration, server fetches creationOptions from Passkey API, and passes them to the client.

2. Create Credential: Client creates the passkey using creationOptions.

3. Finalize Registration: Client sends credential to server, server validates it with Passkey API, and confirms registration to client.

Let's implement it in the code:

Backend:

Make sure you have the passkey SDK installed in your backend.

bun add @teamhanko/passkeys-sdk

Start by creating two functions, startServerPasskeyRegistration and finishServerPasskeyRegistration  which will be used in the /passkeys/register route in the next step.

// src/lib/passkey.ts

import { tenant } from "@teamhanko/passkeys-sdk";
import db from "../db";

const passkeyApi = tenant({
  apiKey: process.env.PASSKEYS_API_KEY!,
  tenantId: process.env.PASSKEYS_TENANT_ID!,
});

async function startServerPasskeyRegistration(userID: string) {
  const user = db.users.find((user) => user.id === userID);
  if (!user) {
    throw new Error("User not found");
  }
  const createOptions = await passkeyApi.registration.initialize({
    userId: user.id,
    username: user.email || "",
  });

  return createOptions;
}

async function finishServerPasskeyRegistration(credential: any) {
  await passkeyApi.registration.finalize(credential);
}

Let's breakdown the above code step by step:

Step 1: Import the necessary dependencies

  • We import the tenant function from the @teamhanko/passkeys-sdk package to initialize the Passkey API.

Step 2: Initialize the Passkey API

  • Create an instance of the Passkey API by calling the tenant function and providing the required configuration, such as the API key and tenant ID.

Step 3: Implement the startServerPasskeyRegistration function

  • This function takes a userID as a parameter and initiates the passkey registration process.
  • It searches for the user in the db.users array based on the provided userID.
  • If the user is found, it calls the passkeyApi.registration.initialize method, passing the user's ID and email as options.
  • The function returns the createOptions object received from the Passkey API.

Step 4: Implement the finishServerPasskeyRegistration function

  • This function takes a credential object as a parameter and finalizes the passkey registration process.
  • It calls the passkeyApi.registration.finalize method, passing the credential object.

Now, set up the API endpoint for passkey registration:

// src/index.ts

import {
  finishServerPasskeyRegistration,
  startServerPasskeyRegistration,
} from "./lib/passkey";

app.post("/passkeys/register", checkAuth, async (c: Context) => {
  const user = await c.get('user');
  const userID = user?.id;

  if (!userID) {
    return c.json({ message: "Unauthorized" }, 401);
  }
  const { start, finish, credential } = await c.req.json();
  if (start) {
    const user = db.users.find((user) => user.id === c.get("user").id);
    if (!user) {
      return c.json({ message: "User not found" }, 404);
    }
    const createOptions = await startServerPasskeyRegistration(user.id);
    return c.json({ createOptions });
  }
  if (finish) {
    await finishServerPasskeyRegistration(credential);
    return c.json({ message: "Registration successful" });
  }
  return c.json({ message: "Invalid request" }, 400);
});
  1. We define a POST route /passkeys/register method. The checkAuth middleware is used to ensure the user is authenticated.
  2. Inside the route handler, we retrieve the authenticated user's ID from the request context using c.get('user').id. If the user ID is not available, we return an "Unauthorized" error response.
  3. Then extract the start, finish, and credential properties from the request body using c.req.json().
  4. If the start property is present, find the user in the db.users array based on the authenticated user's ID.
  5. Call the startServerPasskeyRegistration function with the user's ID to initiate the passkey registration process and obtain the createOptions.
  6. Now return the createOptions in the response.
  7. If the finish property is present, we call the finishServerPasskeyRegistration function with the provided credential to complete the passkey registration.

This API endpoint allows client to initiate and complete the passkey registration.

Frontend:

Install the @github/webauthn-json library in your Solid.js frontend.

bun add @github/webauthn-json

Next, create a registerPasskey function which will be called when the user clicks a button to register a new passkey. It handles the client-side process of registering a passkey.

 const registerPasskey = async () => {
        setIsLoading(true);
        try {
            const createOptionsResponse = await fetch("http://localhost:3000/passkeys/register", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                credentials: 'include',
                body: JSON.stringify({ start: true, finish: false, credential: null }),
            });

            const { createOptions } = await createOptionsResponse.json();
            console.log("createOptions", createOptions);

            const credential = await create(createOptions as CredentialCreationOptionsJSON);
            console.log(credential);

            const response = await fetch("http://localhost:3000/passkeys/register", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                credentials: "include",
                body: JSON.stringify({ start: false, finish: true, credential }),
            });
            console.log(response);

            if (response.ok) {
                console.log("Registration successful");
            } else {
                const errorData = await response.json();
                console.error(`Registration failed: ${errorData.message}`);
            }
        } catch (error) {
            console.error('Registration error:', error);
        } finally {
            setIsLoading(false);
        }
  };

The function starts by sending a POST request to the server at the endpoint /passkeys/register to initiate the passkey registration process. The request includes a JSON payload with the following properties:

  • start: true: Indicates that the registration process is starting.
  • finish: false: Indicates that the registration process is not yet finished.
  • credential: null: Initially, no credential is provided.

The function then awaits the response from the server and extracts the createOptions from the response JSON.

Next, the function calls the create function from @github/webauthn-json library, passing the createOptions as an argument.

After obtaining the credential, the function sends another POST request to the same endpoint, but this time with a different payload:

  • start: false: Indicates that the registration process is no longer starting.
  • finish: true: Indicates that the registration process is finished.
  • credential: The newly created passkey credential.

Finally, if the response is successful, a passkey is registered for the user.

Get credential from Hanko Cloud

Now, to make it work we're missing one crucial step: getting the Tenant ID and API key secret from Hanko Cloud. For that, navigate over to Hanko, create an account, and set up your organization. Navigate to 'Create new project', select 'Passkey Infrastructure', and provide your project name and URL.

Note: It's recommended to create separate projects for your development and production environments. This way, you can use your frontend localhost URL for the development project and your production URL for the live project, ensuring a smooth transition between environments without the need to modify the 'App URL' later.

Obtain your Tenant ID and create an API key, then add the Tenant ID and API key secret to your backend's '.env' file.

Now that you have your secrets added to your backend's .env file, you should be all set to register a passkey. Go ahead and give it a try!

Passkey Login

Backend:

Implementing passkey login will be pretty similar to passkey registration. Create two more functions startServerPasskeyLogin  and finishServerPasskeyLogin  to start and finalize the login process.

async function startServerPasskeyLogin() {
  const options = await passkeyApi.login.initialize();
  return options;
}

async function finishServerPasskeyLogin(options: any) {
  const response = await passkeyApi.login.finalize(options);
  return response;
}

These will be used in the /passkeys/login route below.

app.post("/passkeys/login", async (c: Context) => {
  const { start, finish, options } = await c.req.json();
  try {
    if (start) {
      const loginOptions = await startServerPasskeyLogin();
      return c.json({ loginOptions });
    }
    if (finish) {
      const jwtToken = await finishServerPasskeyLogin(options);
      const userID = await getUserID(jwtToken?.token ?? "");
      const user = db.users.find((user) => user.id === userID);

      if (!user) {
        return c.json({ message: "Invalid user" }, 401);
      }

      const sessionId = uuidv4();
      setUser(sessionId, user);

      console.log(sessionId);
      setCookie(c, "sessionId", sessionId, { httpOnly: true });
      return c.json({ message: "Passkey Login successful" });
    }
  } catch (error) {
    return c.json(
      { message: "An error occurred during the passkey login process." },
      500
    );
  }
});

  • Similar to /passkeys/register,  /passkeys/login POST route is defined.
  • Inside the route handler, we extract the start, finish, and options properties from the request body using c.req.json().
  • If the start property is present, we call the startServerPasskeyLogin function to initiate the passkey login process and obtain the loginOptions.
  • Next, return the loginOptions in the response.
  • If the finish property is present, we call the finishServerPasskeyLogin function with the provided options to complete the passkey login and obtain a JWT token which the Hanko Passkey API returns.
  • Now you can retrieve the userID from JWT using jose and after verifying it against the database, create the session for the user based on your auth implementation and log the user in.
// lib/get-user-id.ts

import * as jose from "jose";

const getUserID = async (token: string): Promise => {
  try {
    const payload = jose.decodeJwt(token);
    const userID = payload.sub;
    return userID;
  } catch (error) {
    console.error("Error decoding JWT:", error);
    return undefined;
  }
};

export default getUserID;

Frontend:

Now we create a signInWithPasskey function. This function will interact with the backend APIs to login the user with their registered passkeys.

import { get } from "@github/webauthn-json";

const signInWithPasskey = async () => {
        setIsLoading(true);
        try {
            const createOptionsResponse = await fetch("http://localhost:3000/passkeys/login", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                credentials: 'include',
                body: JSON.stringify({ start: true, finish: false, credential: null }),
            });

            const { loginOptions } = await createOptionsResponse.json();

            const options = await get(loginOptions);

            const response = await fetch("http://localhost:3000/passkeys/login", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                credentials: 'include',
                body: JSON.stringify({ start: false, finish: true, options }),
            });

            if (response.ok) {
                console.log("User logged in with passkey");
                navigate("/dashboard");
            } else {
                const errorData = await response.json();
                setError(errorData.message);
            }
        } catch (error) {
            console.error("Error during passkey login:", error);
            setError(error.message);
        } finally {
            setIsLoading(false);
        }
    };
    

Thats it! You now have a fully functional passkey login in your Solid.js app, significantly enhancing the user experience and making the authentication process more seamless for your users 🚀

You can always reach out to us on Discord if you run into any issues.

arrow
Back to overview

More blog posts

Don't miss out on latest blog posts, new releases and features of Hanko's products, and more.

Your submission has been received!
Something went wrong.