Guide
12 Min.
Read

Add passkey authentication to your web application

Passkey login is gaining popularity faster than ever before, and for good reason. You must have noticed a new "Sign in with a passkey" option when logging into websites like GitHub or Vercel lately.

Passkeys are a new way to sign in to websites and apps without the need for passwords. They use your device's built-in unlock mechanism, such as Touch ID or Face ID, to authenticate your identity, providing a smooth, secure, and user-friendly login experience compared to traditional passwords and even two-factor authentication methods.

If you're looking to add a passkey login to your existing web app, you're in the right place! In this tutorial, we'll walk you through how you can add passkey authentication to your existing web app, regardless of the framework you're using.

We'll assume that you already have authentication set up, so we won't focus on that here. Instead, we'll dive straight into adding Passkeys to your existing auth system.

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.
  2. A few functions in your 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 passkey functionality 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 now in the code.

Backend:

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

npm i @teamhanko/passkeys-sdk

The startServerPasskeyRegistration function is an asynchronous function that takes a userID parameter and does the following:

  1. Gets the user object from the db.users array using the userID.
  2. Initializes the passkey registration process by calling passkeyApi.registration.initialize with the user's ID and email (or an empty string if email is missing).
  3. Returns the createOptions object from the SDK, which has the data needed to finish the registration on the client-side.

The finishServerPasskeyRegistration function is another asynchronous function that takes a credential parameter. It simply calls passkeyApi.registration.finalize with the credential to complete the passkey registration on the server-side.

These functions are service-level functions that handle the logic for working with the Hanko Passkey SDK. They can be called from controller functions, which are then triggered by the corresponding routes in your application.

// services/passkey.js

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

dotenv.config();

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

async function startServerPasskeyRegistration(userID) {
  const user = db.users.find((user) => user.id === userID);

  const createOptions = await passkeyApi.registration.initialize({
    userId: user.id,
    username: user.email || "",
  });

  return createOptions;
}

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

The handlePasskeyRegister() controller function first extracts the user object from the request (req) and retrieves the userID from it.

Next, it extracts the start, finish, and credential properties from the request body. These properties are used to determine the specific action to be performed.

If the start property is present, the controller function calls thestartServerPasskeyRegistration service function, passing the userID as an argument. This function initializes the passkey registration process and returns the createOptions object. The controller then sends the createOptions back to the client as a JSON response.

If the finish property is present, the controller function calls the finishServerPasskeyRegistration service function, passing the credential obtained from the request body. This function finalizes the passkey registration process on the server-side. Once the registration is complete, the controller sends a JSON response with a success message.

// controllers/passkey.js

import {
  startServerPasskeyRegistration,
  finishServerPasskeyRegistration
} from "../service/passkey.js";
import db from "../db.js";

async function handlePasskeyRegister(req, res) {
  const { user } = req;
  const userID = user.id;

  if (!userID) {
    return res.status(401).json({ message: "Unauthorized" });
  }
  console.log("userId", userID);

  const { start, finish, credential } = req.body;

  try {
    if (start) {
      const createOptions = await startServerPasskeyRegistration(userID);
      console.log("registration start");
      return res.json({ createOptions });
    }
    if (finish) {
      await finishServerPasskeyRegistration(credential);
      return res.json({ message: "Registered Passkey" });
    }
  } catch (error) {
    return res.status(500).json(error);
  }
}

Now, we setup a /passkeys/register POST route. When a POST request is made to this, the handlePasskeyRegister controller function is invoked to handle the passkey registration process.

// routes/passkey.js

import express from "express";
const router = express.Router();
import { handlePasskeyRegister } from "../controllers/passkey.js";
import { checkAuth } from "../middleware/auth.js";


router.post("/passkeys/register", checkAuth, handlePasskeyRegister);

export default router;

Frontend:

Make sure to install the @github/webauthn-json library in your frontend.

npm install @github/webauthn-json

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

The function starts by sending a POST request to the server at the endpoint /api/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.

async function registerPasskey() {
        const createOptionsResponse = await fetch("http://localhost:5001/api/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:5001/api/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) {
            toast.success("Registered passkey successfully!");
            return;
        }
    }

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:

To enable passkey login, we'll create one more service function to initiate and finish the login process.

// services/passkey.js

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

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

Now, similar to handlePasskeyRegister create a handlePasskeyLogin controller.

This function handles process of logging in with a passkey. It receives a request with start, finish, and options properties. If start is true, it initiates the login process by calling startServerPasskeyLogin() and returns the login options. If finish is true, it completes the login process by calling finishServerPasskeyLogin() with the provided options. It then retrieves the user ID from the JWT token, finds the corresponding user in the database, creates a session ID, sets the user for the session, and logs them in.

// controllers/passkey.js

import {
  startServerPasskeyLogin,
  finishServerPasskeyLogin,
} from "../service/passkey.js";
import { getUserID } from "../service/get-user-id.js";
import { v4 as uuidv4 } from "uuid";
import { setUser } from "../service/auth.js";
import db from "../db.js";

async function handlePasskeyLogin(req, res) {
  const { start, finish, options } = req.body;

  try {
    if (start) {
      const loginOptions = await startServerPasskeyLogin();
      return res.json({ loginOptions });
    }
    if (finish) {
      const jwtToken = await finishServerPasskeyLogin(options);
      const userID = await getUserID(jwtToken?.token ?? "");
      console.log("userID from hanko", userID);
      const user = db.users.find((user) => user.id === userID);
      if (!user) {
        return res.status(401).json({ message: "Invalid user" });
      }
      console.log("user", user);
      const sessionId = uuidv4();
      setUser(sessionId, user);
      res.cookie("sessionId", sessionId);
      return res.json({ message: " Passkey Login successful" });
    }
  } catch (error) {
    console.error(error);
    return res
      .status(500)
      .json({ message: "An error occurred during the passkey login process." });
  }
}

In the /passkeys/login POST route, we invoke the above controller.

// routes/passkey.js

router.post("/passkeys/login", handlePasskeyLogin);

Frontend:

Now we create a signInWithPasskey function. This function will interact with the backend APIs to initiate and finalize the passkey login process.

async function signInWithPasskey({ autofill = false }) {
        if (controller) {
            controller.abort();
        }
        controller = new AbortController();

        const createOptionsResponse = await fetch("http://localhost:5001/api/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();

        if (autofill) loginOptions.mediation = "conditional";
        if (autofill) loginOptions.signal = controller.signal;

        // Open "register passkey" dialog
        const options = await get(
            loginOptions,
        );

        const response = await fetch("http://localhost:5001/api/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")
            return;
        }
    }

Congratulations! You now have a fully functional passkey login in your web app, significantly enhancing the user experience and making the authentication process more seamless for your users.

Optional: Passkey Autofill Support

To further enhance the user's login experience, you can add autofill support to your email (or username) fields. This feature allows users to select their passkey directly from the autofill popup that appears when clicking on the input box, streamlining the login process.

When a user clicks on a passkey in the autofill popup, they will be immediately logged in, following the same flow as if they had clicked the "Sign in with passkey" button. This seamless integration provides a more intuitive and efficient way for users to access their accounts, eliminating the need for extra clicks or navigation.

To implement this feature, you'll need to make some additions to your login page. Let's start with that.

let controller: AbortController;


const Login = () => {
    async function signInWithPasskey({ autofill = false }) {

        if (controller) {
            controller.abort();
        }
        controller = new AbortController();

        // previous code...

        if (autofill) loginOptions.mediation = "conditional";
        if (autofill) loginOptions.signal = controller.signal;

        // Open "register passkey" dialog
        const options = await get(
            loginOptions,
        );

       // previous code...
    }


    useEffect(() => {
        signInWithPasskeyAutofill();
    }, []);

    async function signInWithPasskeyAutofill() {
        if (isConditionalMediationAvailable()) {
            try {
                await signInWithPasskey({ autofill: true });
            } catch (error) {
                console.error("Error during Passkey autofill attempt:", error);
            }
        }

    }

    function isConditionalMediationAvailable() {
        return typeof window !== "undefined" &&
            window.PublicKeyCredential?.isConditionalMediationAvailable?.();
    }

    return (
    	<div className="w-screen h-screen flex items-center justify-center">
    	<Input
        id="email"
        required
        name="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        autoComplete="email webauthn"
    	/>
    	<Button className="mt-4 w-full" onClick={() => signInWithPasskey({ 		    autofill: false })}>
        	Sign in with a Passkey
    	</Button>
	</div>
    )
}

export default Login;

When the Login component mounts, it checks if conditional mediation is available using the isConditionalMediationAvailable function. If available, it automatically attempts to sign in with a passkey using the signInWithPasskeyAutofill function.

The signInWithPasskey function takes an optional autofill parameter, which determines whether to use conditional mediation for the autofill popup. If autofill is set to true, the function sets the mediation property of the loginOptions to "conditional" and assigns an AbortController signal to cancel the autofill request if needed.

By adding the autoComplete="email webauthn" attribute to the email input field, we enable the browser's autofill functionality to suggest available passkeys associated with the user's email address. When the user clicks on the input box, the autofill popup appears, allowing them to select their passkey and streamline the login process.

The "Sign in with a Passkey" button will always serve as a fallback option, allowing users to manually initiate the passkey sign-in flow if autofill is not available or if they prefer to use a different passkey.

Optional: Passkey Management for better User Control

In addition to implementing passkey autofill support, providing users with the ability to manage their passkeys can significantly enhance their experience. By offering features such as deleting, renaming, and listing available credentials, you give users greater control over their passkey credentials.

Create two more service-level functions listCredentials and deleteCredential.

The listCredentials function takes a userID as input and retrieves all the passkey credentials associated with that user, whereas deleteCredential function will allow users to remove a specific passkey from their account. It takes a credentialID as input, identifying the passkey to be deleted.

// service/passkey.js

async function listCredentials(userID) {
  const credentials = await passkeyApi.user(userID).credentials();
  return credentials;
}

async function deleteCredential(credentialID) {
  await passkeyApi.credential(credentialID).remove();
}

The listPasskeyCredentials and deletePasskeyCredential controller functions handle the API endpoints for retrieving and deleting passkey credentials. They utilize the listCredentials and deleteCredential service functions, respectively.

// controllers/passkey.js

async function listPasskeyCredentials(req, res) {
  const { user } = req;
  const userID = user.id;

  if (!userID) {
    return res.status(401).json({ message: "Unauthorized" });
  }

  try {
    const credentials = await listCredentials(userID);
    return res.json({ credentials });
  } catch (error) {
    return res.status(500).json(error);
  }
}

async function deletePasskeyCredential(req, res) {
  const { user } = req;
  const userID = user.id;

  if (!userID) {
    return res.status(401).json({ message: "Unauthorized" });
  }

  const { credentialID } = req.params;

  try {
    await deleteCredential(credentialID);
    return res.json({ message: "Credential deleted" });
  } catch (error) {
    return res.status(500).json(error);
  }
}

In the /passkeys/credentials route, you can invoke the above controllers.

// routes/passkey.js

router.get("/passkeys/credentials", checkAuth, listPasskeyCredentials);
router.delete("/passkeys/credentials/:credentialID", checkAuth, deletePasskeyCredential);

On the frontend, you can make API calls to fetch and delete passkey credentials. Here's an example of how you can achieve this:

const fetchCredentials = async () => {
        const response = await fetch('http://localhost:5001/api/passkeys/credentials', {
            method: 'GET',
            credentials: 'include',
        });
        const data = await response.json();
        if (response.ok) {
            setCredentials(data.credentials);
        } else {
            console.error('Failed to fetch credentials');
        }
        fetchCredentials();
    };    

const deleteCredential = async (credentialID: string) => {
        try {
            const response = await fetch(`http://localhost:5001/api/passkeys/credentials/${credentialID}`, {
                method: 'DELETE',
                credentials: 'include',
            });
            if (!response.ok) {
                throw new Error('Failed to delete the credential');
            }
            // Remove the deleted credential from the state to update the UI
            setCredentials(credentials.filter(cred => cred.id !== credentialID));
            console.log("Deleted credential successfully");
        } catch (error) {
            console.error('Error deleting credential:', error);
            // Optionally, show an error message to the user
        }
    };

That's it! You're done. So, along with adding a passkey authentication, we've also added autofill support and passkey management. Feel free to reach out to us on Discord if you get into issues.

Github repo: https://github.com/teamhanko/passkeys-webapp

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.