Guide
8 Min.
Read

Adding passkeys to a Node.js app

This tutorial shows how you can add passkey authentication to your existing Node.js application using Hanko Passkey API. Passkeys enable your users to authenticate without needing any passwords. It uses your user's device lock mechanisms like Touch ID or Face ID, which makes it way more secure than traditional passwords or even two-factor auth methods.

We already have basic session-based authentication set up, where users log in with their email and password. Now, to add passkeys on top of that, we need two main things:

  • First, a backend to handle the authentication flow and store data about your users' passkeys. For this, we'll be setting up an Express.js server.
  • Second, a couple of functions on your frontend to bring up and handle the "Sign in with passkey" and "Register passkey" dialogs. We're using React.js for our frontend, but the process will be pretty much the same for any other frontend framework.

Install the Passkey SDK

Install the JavaScript SDK provided by Hanko and webauth-json package by GitHub. Note that you need to install the SDK on the backend directory and webauthn-json package on the frontend directory.

npm add @teamhanko/passkeys-sdk @github/webauthn-json

Allow your users to register a passkey

When it comes to passkey registration, you'll need to decide where and how your users can add passkeys to their accounts. It's entirely up to you. In our case, we've decided to provide a 'Register Passkey' button on the 'dashboard' page.

On your express backend, you’ll have to use tenant({ ... }).registration.initialize() and .registration.finalize() to create and store a passkey for your user. We'll create a /api/passkeys/register endpoint that will utilize these.

On your frontend, you’ll have to call create() from @github/webauthn-json with the object .registration.initialize() returned.

First, create two services startServerPasskeyRegistration() and finishServerPasskeyRegistration() in the /express-backend/services/passkey.js

In the startServerPasskeyRegistration() function we retrieve the user's information from a database and initiate the passkey registration process by calling the passkeyApi.registration.initialize() method with the user's ID and email. This function returns the options required for creating a new passkey on the client side. The finishServerPasskeyRegistration() function finalizes the registration process by sending the client-side credential to the Passkey API.

These functions are then exported to be used in the express-backend/controllers/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);
}

export {
  startServerPasskeyRegistration,
  finishServerPasskeyRegistration,
};

Now we define handlePasskeyRegister() controller that handles two stages of the passkey registration process: Initiation and completion. The function utilizes the helper functions startServerPasskeyRegistration() and finishServerPasskeyRegistration() we created above.

import {
  startServerPasskeyRegistration,
  finishServerPasskeyRegistration,
} from "../service/passkey.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);
      return res.json({ createOptions });
    }
    if (finish) {
      await finishServerPasskeyRegistration(credential);
      return res.json({ message: "Registered Passkey" });
    }
  } catch (error) {
    return res.status(500).json(error);
  }
}

export { handlePasskeyRegister, handlePasskeyLogin };

Now, you can use it in the specific route, we do it in the /api/passkeys/register endpoint.

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

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

export default router;

The backend part is done, now it's time to add the 'Register Passkey' button on the frontend.

Here, the registerPasskey() function handles the passkey registration process. It first sends a request to the server to initiate the registration process and receives the response for creating a new passkey. It then uses the @github/webauthn-json library to create a new passkey credential based on the received options from the response. Finally, it sends another request to the server with the newly created credential to complete the registration process.

import {
    create,
    type CredentialCreationOptionsJSON,
} from "@github/webauthn-json";
import { useNavigate } from "react-router-dom";

import { Button } from "@/components/ui/button";
import { toast } from "sonner"



const Dashboard = () => {
    
    // your code..

    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;
        }
    }
    return (
     <div className="p-4">
      <Button
        onClick="{() => registerPasskey()}"
        className="flex justify-center items-center space-x-2 mt-8"
      >
        Register a new passkey
      </Button>
     </div>

    )
}

export default Dashboard;

Get credentials 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!

Allow your users to log in with a passkey

Implementing the login flow will be very similar to the passkey registration flow. Inside of /express-backend/services/passkey.js create two more functions startServerPasskeyLogin() and finishServerPasskeyLogin().

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 passkey registration, create a handlePasskeyLogin() controller. Here, after the passkey login process is finished, the finishServerPasskeyLogin() returns a JWT, which we decode using jose to get the User ID, verify it against the database, and create a new session for the user.

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();
      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 passke login process." });
  }
}

Now you can use this controller in the api/passkeys/login route.

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


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

export default router;

Alright, now we just need to create a 'Sign in with a passkey' button on the frontend and use the endpoints we created above.

import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import useLogin from "@/hooks/useLogin";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { get } from "@github/webauthn-json";

const Login = () => {
    const navigate = useNavigate();

    const { loginUser } = useLogin();
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');


    const handleSubmit = async (event: React.FormEvent) => {
        event.preventDefault();
        const success = await loginUser(email, password);
        if (success) {
            navigate('/dashboard');
        }
    };

    // the signInWithPasskey function takes care of logging in the user with passkey
    async function signInWithPasskey() {
        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();

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

        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;
        }
    }
    return (
        <div className="w-screen h-screen flex items-center justify-center">
         <Card className="w-full max-w-lg">
          <CardHeader>
            <CardTitle>Sign In</CardTitle>
            <CardDescription className="">Choose your preferred sign in method</CardDescription>
        </CardHeader>
        <CardContent>
            <div className="flex flex-col">
                <form onSubmit={handleSubmit}>
                    <div className="flex flex-col gap-y-2">
                        <Label>Email</Label>
                        <Input
                            id="email"
                            required
                            name="email"
                            type="email"
                            value={email}
                            onChange={(e) => setEmail(e.target.value)}
                            autoComplete="email"
                        />
                        <Label>Password</Label>
                        <Input
                            id="password"
                            name="password"
                            type="password"
                            value={password}
                            onChange={(e) => setPassword(e.target.value)}
                            autoComplete="current-password"
                        />
                    </div>
                    <Button type="submit" className="mt-4 w-full">Sign in with Email</Button>
                </form>
                <div className="relative mt-4">
                    <div className="absolute inset-0 flex items-center">
                        <span className="w-full border-t" />
                    </div>
                    <div className="relative flex justify-center text-xs uppercase">
                        <span className="bg-background px-2 text-muted-foreground">
                            Or continue with
                        </span>
                    </div>
                </div>
                <Button className="mt-4 w-full" onClick={() => signInWithPasskey()}>Sign in with a Passkey</Button>
            </div>
        </CardContent>
      </Card>
     </div>
    )
}

export default Login;

And done! Head over to the login page and try logging in with your passkey 🚀

Check out the application repo here.

Feel free to reach out to us on Discord if you encounter any issues or just to say hello.

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.