Overview

NextAuth.js is an authentication framework for Next.js applications. It provides an easy way to set up and manage user authentication, including support for various authentication methods and integrations.

This guide shows how to add a passkey sign-in option to NextAuth using Authsignal. Email magic link is used for initial sign up. Once a user is signed in, they can create a passkey for future sign-ins.

1

Create account via email magic link

2

NextAuth creates a session

3

After clicking 'Create passkey', the user can follow the WebAuthn flow to add a passkey

4

The user is prompted to select their passkey for future sign ins

Code example

You can find the full code example referenced in this guide on Github.

Configuration

Enabling authenticators

For the purposes of this example, we have enabled a passkey authenticator and created a tenant for local development in the Authsignal portal.

Configuring authenticators

When creating the passkey authenticator, set the Relying Party ID to localhost. Add an Expected Origin with the URL of your development environment.

Configuring the relying party ID and expected origins

API keys & region URL

We also need to get the API keys and region URL for our tenant.

Retrieving API keys

The secret key, tenant ID, and the region API host need to be set in your env.local file.

env.local
NEXT_PUBLIC_AUTHSIGNAL_TENANT_ID=YOUR_TENANT_ID
NEXT_PUBLIC_AUTHSIGNAL_BASE_URL=YOUR_REGION_API_HOST
AUTHSIGNAL_TENANT_SECRET=YOUR_SECRET_KEY

Create the Next.js application

If you haven’t already, create a new Next.js application: npx create-next-app@latest my-passkey-demo. This example is based on the Pages Router - so select no when asked to use the App Router.

Install NextAuth, and Authsignal’s web and node SDKs:

npm install next-auth @authsignal/browser @authsignal/node

We recommend disabling 1Password browser extensions during development as they can intercept error messages from Authsignal’s SDKs.

Users will need to create an account and sign in before they can create a passkey. For guidance on account creation via email, check out NextAuth’s email documentation. Authsignal’s example repository can serve as a reference too.

NextAuth necessitates a database for the email magic link sign in. Within the scope of this tutorial, we’ve opted for Prisma as our database. For assistance on incorporating a Prisma database, consult NextAuth’s documentation.

Creating a passkey

1. Backend - Track an action

Create a protected endpoint that tracks an action using Authsignal’s Node Server SDK or with a REST call to Authsignal’s Server API. In our example, we’ve created an endpoint called enroll-passkey that checks the session token before tracking an action. Pass the token returned by the track call to your frontend.

pages/api/auth/enroll-passkey.ts
import { Authsignal } from "@authsignal/node";
import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";

const authsignal = new Authsignal({
  apiSecretKey: process.env.AUTHSIGNAL_TENANT_SECRET!,
  apiUrl: process.env.NEXT_PUBLIC_AUTHSIGNAL_BASE_URL,
});

export default async function enrollPasskey(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const sessionToken = await getToken({ req });

  if (!sessionToken || !sessionToken.sub) {
    return res.status(401).json("Unauthenticated");
  }

  const { token } = await authsignal.track({
    userId: sessionToken.sub,
    action: "enroll-passkey",
    scope: "add:authenticators",
  });

  res.status(200).json(token);
};

2. Frontend - Initiate the passkey enrollment flow

In your app’s frontend, call the signUp function using Authsignal’s Web SDK, passing the token returned in step 1. We recommend copying the example repo’s useAuthsignal implementation.

const authsignal = useAuthsignal();
  // Get a short lived token by tracking an action
const enrollPasskey = async () => {
  ...
  ...
  // Get a short lived token by tracking an action
  const enrollPasskeyResponse = await fetch(
    "/api/auth/enroll-passkey"
  );

  const token = await enrollPasskeyResponse.json();

  // Initiate the passkey enrollment flow
  const userName = session.user.email;

  const resultToken = await authsignal.passkey.signUp({
    token,
    userName,
  });
    ...
    ...
};

3. Backend - Validate the result

Create a protected endpoint that validates the result token from step 2.

Pass the result token returned from Authsignal’s passkey.signUp in step 2 to your backend, validating the result of the enrollment server-side. In our example, we’ve created an API route called callback that checks the NextAuth session token, then validates the result token passed in from step 2.

pages/api/auth/callback.ts
import { Authsignal } from "@authsignal/node";
import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";

const authsignal = new Authsignal({
  apiSecretKey: process.env.AUTHSIGNAL_TENANT_SECRET!,
  apiUrl: process.env.NEXT_PUBLIC_AUTHSIGNAL_BASE_URL,
});

export default async function callback(req: NextApiRequest, res: NextApiResponse) {
  const sessionToken = await getToken({ req })

  if (!sessionToken) {
    return res.status(401).json('Unauthenticated');
  }

  const { token } = req.query;

  if (!token || Array.isArray(token)) {
    res.status(400).json("Invalid token");
    return;
  }

  const data = await authsignal.validateChallenge({ token });

  res.status(200).json(data);
};

4. Frontend - Handle result

Notify the user of the success or failure of the passkey addition.

const { isValid } = await callbackResponse.json();

if (isValid) {
  alert("Successfully added passkey");
} else {
  alert("Failed to add passkey");
}

The full frontend enrollment logic is shown below.

const authsignal = useAuthsignal();

const enrollPasskey = async () => {
  if (!session?.user?.email || !session.user) {
    throw new Error("No user in session");
  }

  // Get a short lived token by tracking an action
  const enrollPasskeyResponse = await fetch("/api/auth/enroll-passkey");

  const token = await enrollPasskeyResponse.json();

  // Initiate the passkey enroll flow
  const userName = session.user.email;

  const resultToken = await authsignal.passkey.signUp({
    token,
    userName,
  });

  // Check that the enrollment was successful
  const callbackResponse = await fetch(`/api/auth/callback/?token=${resultToken}`);

  const { isValid } = await callbackResponse.json();

  if (isValid) {
    alert("Successfully added passkey");
  } else {
    alert("Failed to add passkey");
  }
};

You can remove passkeys along with other authenticators in the Authsignal Portal. You will also need to remove it from your device, e.g. in chrome://settings/passkeys.

Signing in with a passkey

Now that the user has enrolled a passkey, the next time they sign in we would like to give them the option to use their passkey instead of email magic link.

1. Frontend - Enable passkey autofill

First, we need to ensure that the input field has the value username webauthn in the autocomplete attribute.

The webauthn value in the autocomplete attribute is required for autofill to work.

webauthn can be combined with other typical autocomplete values, including username and current-password, but must appear at the end to consistently trigger conditional UI across browsers.

<input
  type="email"
  id="email"
  onChange={(input) => setEmail(input.target.value)}
  autoComplete="username webauthn"
/>

Then we call authsignal.passkey.signIn when the page loads. This will initialize the input field for passkey autofill, which means if a user has enrolled a passkey then they should be able to select it when focusing the text field. On success, this will return a token that we will validate on the backend.

const signInToken = await authsignal.passkey.signIn({
  autofill: true,
});

We pass this token to NextAuth’s signIn method, specifying that we want to use the credentials provider. This uses the token to validate the result of the challenge server-side and log the user in. We’ve set redirect: false so that we can handle errors manually, on the current page.

import { signIn } from "next-auth/react";

const result = await signIn("credentials", {
  signInToken,
  redirect: false,
});

The full sign in logic code is shown below:

useEffect(() => {
  const handlePasskeySignIn = async () => {
    // Initialize the input for passkey autofill
    const signInToken = await authsignal.passkey.signIn({
      autofill: true,
    });

    // Run NextAuth's sign in flow. This will run if the user selects one of their passkeys
    // from the WebAuthn dropdown.
    if (signInToken) {
      const result = await signIn("credentials", {
        signInToken,
        redirect: false,
      });

      if (result?.error) {
        alert("Failed to sign in with passkey");
      }

      // Redirect the user from the sign in page to a signed in page
      router.push("/");
    }
  };

  if (status === "unauthenticated") {
    handlePasskeySignIn();
  }
}, [status, authsignal.passkey, router]);

2. Backend - Create NextAuth session

We need to add a CredentialsProvider to the providers array in authOptions within the [...nextauth] API route. This will validate the signInToken from step 1 using Authsignal’s validateChallenge method. On successful validation, NextAuth will create a session containing user information that you specify. Returning null will throw an error.

pages/api/auth/[...nextauth].ts
const authOptions = {
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: "jwt" as SessionStrategy,
  },
  providers: [
    EmailProvider({
      server: {
        host: process.env.SMTP_HOST,
        port: Number(process.env.SMTP_PORT),
        auth: {
          user: process.env.SMTP_USER,
          pass: process.env.SMTP_PASSWORD,
        },
      },
      from: process.env.SMTP_FROM,
    }),
    CredentialsProvider({
      name: "webauthn",
      credentials: {},
      async authorize(cred) {
        const { signInToken } = cred as { signInToken: string };

        if (!signInToken) {
          return null;
        }

        const result = await authsignal.validateChallenge({
          token: signInToken,
        });

        const user = await prisma.user.findUnique({
          where: { id: result.userId },
        });

        if (!user) {
          return null;
        }

        const { state } = result;

        if (state === "CHALLENGE_SUCCEEDED") {
          return { id: user.id, email: user.email };
        }

        return null;
      },
    }),
  ],
  secret: process.env.SECRET,
};

You’ll notice that we’ve also added a JWT strategy to the session option. We need to enable JWT session tokens for NextAuth’s CredentialsProvider to work, according to NextAuth’s documentation.

Conclusion

That’s it! You’ve successfully added both email login with NextAuth, and passkey login by integrating NextAuth with Authsignal.

For more information, check out our passkeys documentation or to test out passkey compatibility on your browser you can play with our demo site.