Australian flagJoin us at the FIDO seminar in Melbourne – Feb 7, 2025!
passkeys-nextauthPasskeys Implementation

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: January 5, 2025


ChangeCorbadoConnect Icon

We're currently reworking the Corbado Connect approach. Reach out if you want to get early access.

Get early access

Overview#

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):

Passkey sign in page

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:

Passkey creation and usage

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

You can read more about the Passkey Associate concept in our docs.

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!).

Corbado developer panel

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):

Corbado developer panel

  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:

NextAuth.js example

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

NextAuth.js sign in 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!

Index page

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 // see https://apireference.cloud.corbado.io/backendapi/#tag/Association-Tokens/operation/AssociationTokenCreate) for details 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:

Index page

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

Passkey sign in 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:

Index page

Click on the Create passkey button and create a passkey.

Creating passkey

Index page

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.

Passkey sign in page

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. If you want to read more about Corbado as an all-in-one authentication solution, please check out our documentation here.

Table of Contents

Enjoyed this read?

🤝 Join our Passkeys Community

Share passkeys implementation tips and get support to free the world from passwords.

🚀 Subscribe to Substack

Get the latest news, strategies, and insights about passkeys sent straight to your inbox.


We provide UI components, SDKs and guides to help you add passkeys to your app in <1 hour

Start for free