Get your free and exclusive +30-page Authentication Analytics Whitepaper

Add Passkeys to your NextAuth.js Application

This tutorial shows how to build a sample app with passkey authentication using Next.js as a web framework and NextAuth.js as authentication library.

Blog-Post-Author
Nicolai

Created: September 6, 2023

Updated: April 15, 2026

passkeys-nextauth

⚠️ Archived tutorial - outdated integration

This article uses Corbado Complete, a discontinued product. Corbado now offers passkey observability and authentication for enterprise CIAM

The tutorial below is preserved for reference only and is no longer maintained.

PasskeysCheatsheet Icon

Looking for a dev-focused passkey reference? Download our Passkeys Cheat Sheet. Trusted by dev teams at Ally, Stanford CS & more.

Get Cheat Sheet
Key Facts
  • Passkey Association is the core integration pattern: existing users authenticated via OAuth or password add a passkey to their account, then use it for future logins.
  • The corbado-passkey-associate-login web component handles all WebAuthn complexity including challenge generation, challenge verification and Conditional UI automatically.
  • NextAuth.js requires a CredentialsProvider configured with a custom authorize method to bridge Corbado passkey authentication into a standard NextAuth session.
  • After passkey login, Corbado issues a cbo_short_session cookie containing a JWT that must be verified against Corbado's JWKS endpoint before NextAuth creates its own session.
  • Corbado supports passkey sign-up with passkey login and email links as fallback, beyond the associate-only flow shown in this tutorial.

1. Introduction#

In this blog post, we'll be walking through the process of building a sample application with passkey authentication using Next.js as a web framework and NextAuth.js as authentication library. To make passkeys work, we plug Corbados Passkey Associate web component into the NextAuth.js authentication process. It automatically connects to a passkeys backend and handles all passkeys related aspects of authentication (e.g. generating and verifying the WebAuthn challenge, handling Conditional UI, etc).

If you want to see the finished code, please have a look at our sample application GitHub repository.

Our final login page looks as follows (of course you can improve the styling but our focus is on demonstrating the feasibility):

2. NextAuth.js passkey project prerequisites#

This tutorial assumes basic familiarity with Next.js, NextAuth.js, TypeScript and HTML. Lets dive in!

3. Repository structure for NextAuth.js passkey project#

Our Next.js project with integrated NextAuth.js authentication is structured like this.

Note that these are only the most important files.

├── pages | ├── api | | ... | | └── auth | | ├── [...nextauth].ts # Configuration of the authentication providers | | └── associate.ts # Endpoint which requests an association token from Corbado | | | ├── auth | | ├── redirect.tsx # Page where the user gets redirected to by Corbado after authentication | | └── signin.tsx # Sign in page which also contains the Corbado web component | | | └── index.tsx # Main page which is shown when no path is given | ├── .env.local # Contains the environment variables

We use a concept called Passkey Association where an already authenticated user (e.g. via OAuth, password or any other conventional method) can add a passkey to their account and subsequently login with this passkey. This means that a user cannot directly register using a passkey, but the user can add a passkey once they have created an account.

Creating a passkey account and using it to log into your app will work as shown here:

The diagram show the following processes:

Creating a passkey (1-4):

  1. The application requests an association token for a certain authenticated user from our backend

  2. Our backend requests this association token from Corbado's backend

  3. Corbado's backend returns the token to our backend

  4. Our backend returns the token to our frontend and the frontend hands it to the web component which will then display a Create passkey button

This enables the user to create a passkey which they can then use to login as follows:

Login with a passkey (5-9):

  1. After the user has entered his email into the Corbado-passkey-associate- login webcomponent, the webcomponent gets a challenge from Corbado's backend

  2. The webcomponent sends back the challenge response to the Corbado backend

  3. If the authentication was successful the user is redirected to the redirectURL (You can configure it in the developer panel), if the authentication was unsuccessful you get a notification so you can display an error

  4. In the redirectPage we tell NextAuth that someone has logged in with a passkey

  5. We obtain the current user from Corbado's session and hand it to NextAuth which will initiate a session itself for that user and proceed as normal

4. Set up your Corbado account and project#

Visit the Corbado developer panel to sign up and create your account (youll see passkey sign-up in action here!).

In the appearing project wizard, select Web app as type of app and afterward select whether you have existing users or not (which does not matter to us in this case because only existing, authenticated users can add a passkey). Moreover, providing some details regarding your frontend and backend tech stack as well as the main goal you want to achieve with Corbado helps us to customize and smoothen your developer experience.

Next, we navigate to Settings > General > URLs and set the Application URL, Redirect URL and Relying Party ID to the following values (We will host our app on port 3000):

  1. Application URL: Provide the URL where you embedded the web component, http://localhost:3000/auth/signin
  2. Redirect URL: Provide the URL your app should redirect to after successful authentication and which gets sent a short-term session cookie, here: http://localhost:3000/auth/redirect
  3. Relying Party ID: Provide the domain (no protocol, no port and no path) where passkeys should be bound to, here: localhost

5. Set up Next.js with NextAuth.js#

We download the NextAuth.js example from here and run

npm install && npm run dev

The default page should appear:

When we click on Sign in, we are redirected to the NextAuth.js default authentication page.

For demo purposes, we went to the Google developer console and generated credentials to be able to use Google as an OAuth provider. The NextAuth.js example reads the OAuth credentials for all providers from the environment, so we create a .env.local file and place our Google credentials there.

NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET= // ... GOOGLE_ID= GOOGLE_SECRET=

Afterwards, we can sign in using our Google account!

Our initial project is now set up. Lets add passkey authentication!

6. Create passkey login page#

6.1 Create custom login page for NextAuth.js providers#

Apart from email magic links and OAuth providers, the UI of the NextAuth.js default authentication page only supports custom inputs in the form of text fields as a separate way of authentication. We, however, want to use passkeys, which cannot be used via text fields. Therefore, we create our own login page under /pages/auth/signin.tsx with some bootstrapping:

"use client"; import type { GetServerSidePropsContext, InferGetServerSidePropsType, NextApiRequest, NextApiResponse, } from "next"; import { getProviders, signIn } from "next-auth/react"; import { getServerSession } from "next-auth/next"; import { authOptions } from "../api/auth/[...nextauth]"; import { useCallback, useEffect, useState } from "react"; export default function SignIn( { providers }: InferGetServerSidePropsType<typeof getServerSideProps>, req: NextApiRequest, res: NextApiResponse, ) { const [session, setSession] = useState<any>(null); var providersNew = Object.values(providers); useEffect(() => { // Refresh the session whenever it changes if (session) { session.refresh(() => {}); } }, [session]); return ( <> <div className="parent"> <div className="buttons"> {providersNew.map((provider) => ( <div key={provider.name}> <button className="btn btn-primary button" onClick={() => signIn(provider.id)} > Sign in with {provider.name} </button> </div> ))} </div> </div> <style jsx>{` .parent { width: 100%; margin-left: auto; margin-right: auto; align-items: center; } .button { margin-left: auto; margin-right: auto; margin-top: 10px; margin-bottom: 10px; display: block; border-radius: 30px; background-color: #1853fe; } `}</style> </> ); } export async function getServerSideProps(context: GetServerSidePropsContext) { const session = await getServerSession(context.req, context.res, authOptions); // If the user is already logged in, redirect. // Note: Make sure not to redirect to the same page // To avoid an infinite loop! if (session) { return { redirect: { destination: "/" } }; } const providers = await getProviders(); return { props: { providers: providers ?? [] }, }; }

To clarify: We only create the UI ourselves. The authentication is still handled by NextAuth.js and gets initiated when we call the signIn method that NextAuth.js provides.

6.2 Embed the passkey authentication webcomponent#

We now add the to our signIn page.

"use client"; import type { GetServerSidePropsContext, InferGetServerSidePropsType, NextApiRequest, NextApiResponse, } from "next"; import { getProviders, signIn } from "next-auth/react"; import { getServerSession } from "next-auth/next"; import { authOptions } from "../api/auth/[...nextauth]"; import { useCallback, useEffect, useState } from "react"; import "@corbado/webcomponent/pkg/auth_cui.css"; const projectID = process.env.CORBADO_PROJECT_ID; export default function SignIn( { providers }: InferGetServerSidePropsType<typeof getServerSideProps>, req: NextApiRequest, res: NextApiResponse, ) { const [session, setSession] = useState<any>(null); var providersNew = Object.values(providers); providersNew = providersNew.filter(function (el) { return el.name != "webauthn"; }); useEffect(() => { // This will run only on client-side import("@corbado/webcomponent") .then((module) => { const Corbado = module.default || module; ("Initializing Corbado session"); setSession(new Corbado.Session(projectID)); }) .catch((err) => {}); }, []); useEffect(() => { // Refresh the session whenever it changes if (session) { session.refresh(() => {}); } }, [session]); return ( <> <div className="parent"> <div className="buttons"> {providersNew.map((provider) => ( <div key={provider.name}> <button className="btn btn-primary button" onClick={() => signIn(provider.id)} > Sign in with {provider.name} </button> </div> ))} </div> <div className="associate-container"> <corbado-passkey-associate-login project-id={projectID} /> </div> </div> <style jsx>{` .parent { width: 100%; margin-left: auto; margin-right: auto; align-items: center; } .button { margin-left: auto; margin-right: auto; margin-top: 10px; margin-bottom: 10px; display: block; border-radius: 30px; background-color: #1853fe; } .associate-container { width: 200px; margin-left: auto; margin-right: auto; align-items: center; } `}</style> </> ); } export async function getServerSideProps(context: GetServerSidePropsContext) { const session = await getServerSession(context.req, context.res, authOptions); // If the user is already logged in, redirect. // Note: Make sure not to redirect to the same page // To avoid an infinite loop! if (session) { return { redirect: { destination: "/" } }; } const providers = await getProviders(); return { props: { providers: providers ?? [] }, }; }

Notice how we also filter out the webauthn authentication provider (our CredentialsProvider).

6.3 Create redirect page#

After successful authentication, the Corbado web component redirects the user to the Redirect URL we configured in step 4 (/auth/redirect).

We create a file under /pages/auth/redirect.tsx and add the following code. This just calls the signIn method of NextAuth.js, but with the parameters credentials instead of an OAuth-provider-id.

import type { GetServerSidePropsContext, InferGetServerSidePropsType, NextApiRequest, NextApiResponse, } from "next"; import { getProviders, signIn } from "next-auth/react"; import { getServerSession } from "next-auth/next"; import { authOptions } from "../api/auth/[...nextauth]"; export default function Redirect( { providers }: InferGetServerSidePropsType<typeof getServerSideProps>, req: NextApiRequest, res: NextApiResponse, ) { signIn("credentials", { provider: "corbado" }); return ( <> <p>Authenticating...</p> </> ); } export async function getServerSideProps(context: GetServerSidePropsContext) { const session = await getServerSession(context.req, context.res, authOptions); // If the user is already logged in, redirect. // Note: Make sure not to redirect to the same page // To avoid an infinite loop! if (session) { return { redirect: { destination: "/" } }; } const providers = await getProviders(); return { props: { providers: providers ?? [] }, }; }

6.4 Add credentials provider for the custom passkey login#

To be able to call NextAuth.jss signIn method with credentials as parameter, we need to configure a custom authentication provider, so NextAuth.js knows what to do and when to generate a session for which user.

The authentication providers for NextAuth.js are configured in /pages/api/auth/[¦nextauth].ts.

Here, we add a CredentialsProvider to incorporate our custom authentication option. The CredentialsProvider contains an authorize method which gets called by NextAuth.js after we use the signIn method (step 6.3).

We obtain the current user from the session that Corbado has initiated. By returning an object containing email and name, we tell NextAuth.js the current user and NextAuth.js will therefore initialize a session itself, which will then be used in all of your application pages.

import NextAuth, { NextAuthOptions } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import FacebookProvider from "next-auth/providers/facebook"; import GithubProvider from "next-auth/providers/github"; import TwitterProvider from "next-auth/providers/twitter"; import Auth0Provider from "next-auth/providers/auth0"; import CredentialsProvider from "next-auth/providers/credentials"; import * as jose from "jose"; const projectID = process.env.CORBADO_PROJECT_ID; export const authOptions: NextAuthOptions = { // https://next-auth.js.org/configuration/providers/oauth providers: [ FacebookProvider({ clientId: process.env.FACEBOOK_ID, clientSecret: process.env.FACEBOOK_SECRET, }), GithubProvider({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, }), GoogleProvider({ clientId: process.env.GOOGLE_ID, clientSecret: process.env.GOOGLE_SECRET, }), TwitterProvider({ clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET, }), Auth0Provider({ clientId: process.env.AUTH0_ID, clientSecret: process.env.AUTH0_SECRET, issuer: process.env.AUTH0_ISSUER, }), CredentialsProvider({ name: "webauthn", credentials: {}, async authorize(cred, req) { if (cred.provider !== "corbado") return null; // Get the token from the cookie var cbo_short_session = req.headers.cookie .split("; ") .find((row) => row.startsWith("cbo_short_session")); var token = cbo_short_session.split("=")[1]; // Get the JWKS URL from the project ID var issuer = "https://" + projectID + ".frontendapi.corbado.io"; var jwksUrl = issuer + "/.well-known/jwks"; // Initialize the JWKS client const JWKS = jose.createRemoteJWKSet(new URL(jwksUrl), { cacheMaxAge: 10 * 60 * 1000, }); const options = { issuer: issuer, }; try { // Verify the token const { payload } = await jose.jwtVerify(token, JWKS, options); if (payload.iss === issuer) { // //Next steps: Load data from database here to always have all the data available in the session return { email: payload.email, name: payload.name, image: null }; } else { console.log("issuer not valid"); } } catch (e) { console.log("Error: ", e); } }, }), ], theme: { colorScheme: "light", }, callbacks: { async jwt({ token }) { token.userRole = "admin"; return token; }, }, pages: { signIn: "/auth/signin", }, }; export default NextAuth(authOptions);

7. Add passkey associate component#

The web component will allow an authenticated user to add a passkey to their account and afterwards login with it using the web component we already integrated.

To get our association token, we create our own endpoint under /pages/api/auth/association.ts which requests a token for a specific user from the Corbado backend and returns it to us.

const Corbado = require("@corbado/node-sdk"); import type { NextApiRequest, NextApiResponse } from "next"; // sind nicht im passenden .env Example const projectID = process.env.CORBADO_PROJECT_ID; const apiSecret = process.env.CORBADO_API_SECRET; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const config = new Corbado.Configuration(projectID, apiSecret); const corbado = new Corbado.SDK(config); const { loginIdentifier, loginIdentifierType } = req.body; try { // use the Corbado SDK to create the association token const associationToken = await corbado.associationTokens.create( loginIdentifier, loginIdentifierType, { remoteAddress: req.headers["x-forwarded-for"] || req.socket.remoteAddress, userAgent: req.headers["user-agent"], }, ); if (associationToken?.data?.token) { return res.status(200).send(associationToken.data.token); } else { return res.status(200).send({ error: "error_creating_association_token" }); } } catch (err) { console.log(err); res.status(200).send({ error: "error_creating_association_token" }); } }

In our frontend, we use this endpoint to get the association token for our current user and have the web component display the Create Passkey button:

import Layout from "../components/layout"; import { signIn, signOut, useSession } from "next-auth/react"; import React, { useCallback, useEffect, useState } from "react"; import axios from "axios"; import("@corbado/webcomponent"); interface AssociationToken { associationToken: string; } const PASSKEY_CREATION_SUCCESSFUL = "PASSKEY_CREATION_SUCCESSFUL"; const PASSKEY_CREATION_FAILED = "PASSKEY_CREATION_FAILED"; const DEVICE_NOT_PASSKEY_READY = "DEVICE_NOT_PASSKEY_READY"; export default function IndexPage() { const { data: session, status } = useSession(); const [associationToken, setAssociationToken] = useState<AssociationToken | null>( null, ); const [ref, setRef] = useState<any | null>(null); const [hasPasskey, setHasPasskey] = useState<boolean>(false); const [hasCheckedPasskey, setHasCheckedPasskey] = useState<boolean>(false); const [passkeyReady, setPasskeyReady] = useState<boolean>(true); // The following event handlers can be used to react to different events from the web component const onPasskeyCreationSuccessful = useCallback((_event: CustomEvent) => { console.log("Passkey creation successful"); setAssociationToken(null); setPasskeyReady(true); setHasPasskey(true); }, []); const onPasskeyCreationFailed = useCallback((_event: CustomEvent) => { console.log("Passkey creation failed"); setAssociationToken(null); setHasCheckedPasskey(false); }, []); const onDeviceNotPasskeyReady = useCallback((_event: CustomEvent) => { console.log("Device not passkey ready"); setAssociationToken(null); setPasskeyReady(false); setHasPasskey(false); }, []); // Create and remove the event listeners useEffect(() => { if (ref) { ref.addEventListener( PASSKEY_CREATION_SUCCESSFUL, onPasskeyCreationSuccessful, ); ref.addEventListener(PASSKEY_CREATION_FAILED, onPasskeyCreationFailed); ref.addEventListener(DEVICE_NOT_PASSKEY_READY, onDeviceNotPasskeyReady); } // Cleanup function return () => { if (ref) { ref.removeEventListener( PASSKEY_CREATION_SUCCESSFUL, onPasskeyCreationSuccessful, ); ref.removeEventListener(PASSKEY_CREATION_FAILED, onPasskeyCreationFailed); ref.removeEventListener( DEVICE_NOT_PASSKEY_READY, onDeviceNotPasskeyReady, ); } }; }, [ ref, onPasskeyCreationSuccessful, onPasskeyCreationFailed, onDeviceNotPasskeyReady, ]); const handleButtonClick = async () => { try { // loginIdentifier needs to be obtained via a backend call or your current state / session management // it should be a dynamic value depending on the current logged-in user const response = await axios.post<AssociationToken>("/api/auth/associate", { loginIdentifier: session.user.email, loginIdentifierType: "email", }); setHasCheckedPasskey(true); setHasPasskey(response.data.error != undefined); console.log("AssociationToken response: ", response.data); if (response.data.error == undefined) { setAssociationToken(response.data); } } catch (err) { console.log(err); } }; if (session?.user != undefined && !hasCheckedPasskey) { handleButtonClick(); } return ( <Layout> <h1>NextAuth.js Example</h1> <p> This is an example site to demonstrate how to use{" "} <a href="https://next-auth.js.org">NextAuth.js</a> together with{" "} <a href="https://corbado.com">Corbado</a> for passkey authentication. </p> {!session?.user && ( <> <p> When you are logged in, you can add a passkey to your account here! </p> </> )} {session?.user && !hasPasskey && ( <> {!associationToken && ( <button onClick={handleButtonClick}> Add passkey to my account </button> )} {associationToken && !hasPasskey && ( <div className="associate-container"> <corbado-passkey-associate project-id="pro-2808756695548043260" association-token={associationToken} ref={setRef} /> </div> )} <style jsx>{` .associate-container { width: 200px; margin-left: auto; margin-right: auto; align-items: center; } `}</style> </> )} {session?.user && hasPasskey && ( <> <p> <strong> You have already registered a passkey on this device! </strong> </p> </> )} {!passkeyReady && ( <> <p> <strong>Your device is not passkey ready!</strong> </p> </> )} </Layout> ); }

Thats it our app is fully set up and configured!

8. Start using passkeys with our NextAuth.js implementation#

To start our application we execute

npm install

and afterwards

npm run dev

When visiting http://localhost:3000 you should see the following screen:

Clicking on "Sign in" should redirect you to our custom signIn page:

Now login with Google or any other provider. After authentication, you will be sent to the index page where you have the possibility to add a passkey to your account:

Click on the Create passkey button and create a passkey.

Sign out and go to the "Sign in" page again. If you now enter the email of the OAuth account into the Corbado web component, you should be able to log in using the passkey you just created.

9. Conclusion#

This tutorial showed how easy it is to add passwordless authentication with passkeys to NextAuth.js using Corbado. We only used Corbado for passkey login, but Corbado can also provide passkey sign-up together with passkey login and email links as fallback.

Frequently Asked Questions#

How does the passkey login flow connect to NextAuth.js session management?#

After the Corbado web component authenticates the user, it redirects to a configured redirect page that calls NextAuth's signIn method with credentials parameters. NextAuth then verifies the Corbado short-session JWT via JWKS and, if valid, creates its own session for that user.

Can users register a new account directly with a passkey in a NextAuth.js integration?#

No, not with the Passkey Association approach described here. Users must first create an account and authenticate via an existing provider such as Google OAuth, then add a passkey to their account for future logins.

What environment variables and Corbado developer panel settings do I need to configure?#

You need to set CORBADO_PROJECT_ID and CORBADO_API_SECRET in your environment. In the Corbado developer panel you must configure the Application URL, Redirect URL and Relying Party ID, which for local development are your localhost signin page, your redirect page and 'localhost' respectively.

How does NextAuth.js verify that a passkey authentication from Corbado is legitimate?#

The CredentialsProvider's authorize method reads the cbo_short_session cookie, then uses the jose library to verify the JWT against Corbado's remote JWKS endpoint at projectID.frontendapi.corbado.io/.well-known/jwks, checking the issuer claim before returning user data to NextAuth.

See how Corbado fits your passkey rollout and existing authentication stack.

Explore the Console

Share this article


LinkedInTwitterFacebook