This is an example implementation showing how to use the Passkey API with Node.js. (You can of course use any language you like!)

However, if you’re already using JavaScript/TypeScript for your backend, you can use @teamhanko/passkeys-sdk, which handles all of the below for you.

Otherwise, please make sure to always send JSON with Content-Type: application/json.

As of writing, for the frontend, the Web Authentication API expects you to pass ArrayBuffer (instead of plain old objects) in a lot of places, which can be inconvenient.

In the examples below, we use @github/webauthn-json, which is a wrapper for the Web Authentication API to make things easier.


1

Get your tenant ID and API key

Get your tenant ID and API key from your Hanko Cloud project dashboard.

The base URL for the Passkey API depends on your tenant_id.

.env
PASSKEY_TENANT_ID=your-tenant-id
PASSKEY_SECRET_API_KEY=your-secret-api-key

If you self-host the Passkey API, there are endpoints that let you create, list, and manage tenants programmatically. See the API reference.

2

Add endpoints to start and finish passkey registration

Registering passkeys is a two-step process. First, let’s add an endpoint to our backend. This example uses Express.js, but you can use whatever language or framework you prefer.

Backend:

const tenantId = process.env.PASSKEY_TENANT_ID;
if (!tenantId) throw new Error("Missing PASSKEY_TENANT_ID");

const apiKey = process.env.PASSKEY_SECRET_API_KEY;
if (!apiKey) throw new Error("Missing PASSKEY_SECRET_API_KEY");

const baseUrl = `https://passkeys.hanko.io/${tenantId}`;
const headers = { apiKey, "Content-Type": "application/json" };

app.post("/passkey/start-registration", async (req, res) => {
  // Remember: to register a passkey, the user needs to be logged in first.
  //           Once the passkey is added to the user's account, they can
  //           use it to log in.

  // This is the currently logged in user:
  const user = req.session.user;

  // Send the id and name of the user stored in our DB.
  // (both fields are required)
  const creationOptions = await fetch(baseUrl + "/registration/initialize", {
    method: "POST",
    headers,
    body: JSON.stringify({
      user_id: user.id.toString(), // Must be a string!
      username: user.username,
    }),
  }).then((res) => res.json());

  // creationOptions is an object that can directly be passed to create()
  // (the function that opens the "create passkey" dialog)
  // in the frontend.
  res.json(creationOptions);
});

app.post("/passkey/finalize-registration", async (req, res) => {
  const data = await fetch(baseUrl + "/registration/finalize", {
    method: "POST",
    headers,
    body: JSON.stringify(req.body), // Forward newly created credential
  }).then((res) => res.json());

  // The response from the Passkey API contains a JWT (`data.token`).
  // What you do with this JWT is up to you.
  //
  //
  // The JWT contains 4 claims:
  // "sub": the user_id we used for "/registration/initialize".
  //        you can use this to issue a session for the user, for example
  //
  // "cred": the credential_id (ID of the passkey the user chose)
  //
  // "aud": an array with a single string.
  //        the string is the ID of the relying party (your app).
  //        e.g. ["example.com"]
  //
  // "iat": the expiration date (very short)
  //
  //
  // Here, we don't need to do anything with it, since the user already
  // is logged in. In the login endpoints, later in this guide, we'll
  // use the data contained in the JWT to create a session for our user.

  res.redirect("/success");
});

Frontend:

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

async function registerPasskey() {
  // Let's send a request to our backend to start the registration process.
  // The response JSON can directly be passed to create(...) below.
  const creationOptions = await fetch("/passkey/start-registration", {
    method: "POST",
  }).then((res) => res.json());

  // Open "create passkey" dialog
  const credential = await create(creationOptions);

  // User successfully created a passkey on their device.
  //
  // The resulting `credential` object needs to be sent back to the
  // Passkey API as-is, through our backend:
  //
  // frontend → backend → passkey API
  return fetch("/passkey/finalize-registration", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(credential),
  });
}

Here’s what the whole flow looks like:

As you can see, there are two steps here (“start” and “finalize”), which pass through the frontend, backend, and Passkey API.

The process looks very similar for logging in — it’s also a two-step process where your frontend, backend, and the Passkey API are involved.

3

Add endpoints to start and finish logging in

Similar to how registering a passkey is a two-step process, so is logging in.

Backend:

app.post("/passkey/start-login", async (req, res) => {
  const loginOptions = await fetch(baseUrl + "/login/initialize", {
    method: "POST",
    headers,
  }).then((res) => res.json());

  // loginOptions is an object that can directly be passed to get()
  // (the function that opens the "select passkey" dialog)
  // in the frontend.
  res.json(loginOptions);
});

app.post("/passkey/finalize-login", async (req, res) => {
  const data = await fetch(baseUrl + "/login/finalize", {
    method: "POST",
    headers,
    body: JSON.stringify(req.body), // Credential the user selected
  }).then((res) => res.json());

  // Like when registering, data.token is a JWT that contains claims
  // about the user (see above)

  res.redirect("/success");
});

Frontend:

async function loginWithPasskey() {
  const loginOptions = await fetch("/passkey/start-login", {
    method: "POST",
  }).then((res) => res.json());

  // Open "select passkey" dialog
  const credential = await get(loginOptions);

  // User selected a passkey to use.
  //
  // The returned `credential` object needs to be sent back to the
  // Passkey API as-is.
  return fetch("/passkey/finalize-login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(credential),
  });
}

For logging in, the server can also talk to the Passkey API directly, instead of going through your backend first. Whether you go with the server-first or client-first approach is up to preference. See Client-First Login Flow.