Guide
5 Min.
Read

Add passkeys to your Next.js app using NextAuth

Passkey login is a cool new way to sign in to websites and apps without needing any passwords. It uses your device's unlock mechanism like Touch ID or Face ID to authenticate, which makes it way more secure and easier to use than old-school passwords and even those two-factor authentication methods we're used to.

Typically, to add passkeys to any app, you'd need two things:

  • a backend to handle the authentication flow and store data about your user's passkeys
  • a couple of functions on your frontend to bring up & handle the "Sign in with passkey" dialog

If you're using Next.js, you can easily do both of these things with NextAuth and our accompanying provider @teamhanko/passkeys-next-auth-provider.

Our open-source passkey server is an API you can call from your (Next.js) backend to handle the authentication flow (and storing all relevant data about your user's passkeys).

@teamhanko/passkeys-next-auth-provider is a NextAuth provider that calls this API for you, and lets you bring up the "Sign in with passkey" with a single function call.

@github/webauthn-json is an optional package that makes it easier to work with the WebAuthn API on your frontend.

Not using NextAuth? No worries! We've got a guide that shows you how to implement passkeys to your Next.js app using our Passkey SDK. It works with whatever authentication system you're already using.

Let's get to building. You can either follow the video or go through the guide below.

Install dependencies

After initialising your Next.js app with NextAuth, install the passkey provider for NextAuth from Hanko and webauthn-json package by GitHub.

npm add @teamhanko/passkeys-next-auth-provider @github/webauthn-json

Change NextAuth config

PasskeyProvider({
    tenant: tenant({
        apiKey: process.env.PASSKEYS_API_KEY!,
        tenantId: process.env.NEXT_PUBLIC_PASSKEYS_TENANT_ID!,
    }),
    async authorize({ userId }) {
        const user = await prisma.user.findUnique({ where: { id: userId } });
        if (!user) return null;
        return user;
    },
}),

As we’re using Prisma Adapter by NextAuth, we’ll need to also to use session: { strategy: "jwt" } and modify the session to get id from token.sub. This is how the complete code for auth.ts the file looks after adding Hanko passkey provider.

Note that, if you don’t plan to use any adapters, you just need to add the passkey provider and don’t modify anything.

import type { NextAuthOptions } from "next-auth";
import GitHubProvider from "next-auth/providers/github";
import EmailProvider from "next-auth/providers/email";
import { PrismaAdapter } from "@auth/prisma-adapter";
import {
  tenant,
  PasskeyProvider,
} from "@teamhanko/passkeys-next-auth-provider";

import prisma from "./db";

export const authOptions = {
  providers: [
    GitHubProvider({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET_ID!,
      allowDangerousEmailAccountLinking: true,
    }),
    EmailProvider({
      server: {
        host: process.env.EMAIL_SERVER_HOST,
        port: process.env.EMAIL_SERVER_PORT,
        auth: {
          user: process.env.EMAIL_SERVER_USER,
          pass: process.env.EMAIL_SERVER_PASSWORD,
        },
      },
      from: process.env.EMAIL_FROM,
    }),
    PasskeyProvider({
      tenant: tenant({
        apiKey: process.env.PASSKEYS_API_KEY!,
        tenantId: process.env.NEXT_PUBLIC_PASSKEYS_TENANT_ID!,
      }),
      async authorize({ userId }) {
        const user = await prisma.user.findUnique({ where: { id: userId } });
        if (!user) return null;
        return user;
      },
    }),
  ],
  adapter: PrismaAdapter(prisma),
  session: { strategy: "jwt" },
  callbacks: {
    session: ({ session, token }) => {
      if (token) {
        return {
          ...session,
          user: {
            ...session.user,
            id: token.sub,
          },
        };
      } else {
        return session;
      }
    },
  },
} satisfies NextAuthOptions;

Allow your users to register passkeys

Your users will have to add passkeys to their account somehow. It’s up to you how and where you let them do this, but typically this would be a button on an “Account Settings” page.

On your backend, you’ll have to call tenant({ ... }).registration.initialize() and .registration.finalize() to create and store a passkey for your user.

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

create() will return a PublicKeyCredential object, which you’ll have to pass to .registration.finalize().

Here we have created a new file named passkey.ts, inside of the server directory.

"use server"

import { getServerSession } from "next-auth";
import { tenant } from "@teamhanko/passkeys-next-auth-provider";
import prisma from "./db";
import { authOptions } from "./auth";

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

export async function startServerPasskeyRegistration() {
    const session = await getServerSession(authOptions);
    const sessionUser = session?.user;

    const user = await prisma.user.findUnique({
        where: { email: sessionUser?.email as string },
        select: { id: true, name: true },
    });

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

    return createOptions;
}

export async function finishServerPasskeyRegistration(credential: any) {
    const session = await getServerSession(authOptions);
    if (!session) throw new Error("Not logged in");

    await passkeyApi.registration.finalize(credential);
}

Alright, now let’s get to creating a ‘Create passkey’ button. This is what kicks off the whole Passkey creation process for that user account.

"use client"

import { finishServerPasskeyRegistration, startServerPasskeyRegistration } from '@/lib/passkey';
import { Button } from './ui/button';
import Passkey from './icons/passkey';
import {
    create,
    type CredentialCreationOptionsJSON,
} from "@github/webauthn-json";

const RegisterNewPasskey = () => {
    async function registerPasskey() {
        const createOptions = await startServerPasskeyRegistration();
        const credential = await create(createOptions as CredentialCreationOptionsJSON);
        await finishServerPasskeyRegistration(credential);
    }
    return (
        <Button
            onClick={() => registerPasskey()}
            className="flex justify-center items-center space-x-2"
        >
            <Passkey className="w-4 h-4 mr-2" />
            Register a new passkey
        </Button>
    )
}

export default RegisterNewPasskey

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!

Add a button to login with a passkey

Now that the passkey is successfully registered, let’s add a ‘Sign in with a passkey’ button. This will allow users to easily login using their passkey.

"use client"

import Passkey from "./icons/passkey";
import { Button } from "./ui/button";
import { signInWithPasskey } from "@teamhanko/passkeys-next-auth-provider/client";

const SignInWithPasskey = () => {
    return (
        <Button 
            onClick={() => signInWithPasskey({ 
                tenantId: process.env.NEXT_PUBLIC_PASSKEYS_TENANT_ID!, 
                callbackUrl: `${window.location.origin}/dashboard/settings` 
                })
            } 
            className="mt-4" variant="secondary"
        > 
            <Passkey className="w-4 h-4 mr-2" /> 
            Sign in with a passkey 
        </Button>
    )
}

export default SignInWithPasskey

And that’s it! You now have a working passkey login, making the authentication process much easier for your users 🚀

If you want to take a closer look or try it out for yourself, feel free to check out the GitHub starter repo.

If you run into any issues, don't hesitate to shoot us a message on Discord! We'll help you get things up and running.

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.