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.
Nicolai
Created: September 6, 2023
Updated: July 24, 2024
We're currently reworking the Corbado Connect approach. Reach out if you want to get early access.
Get early access2. NextAuth.js passkey project prerequisites
3. Repository structure for the NextAuth.js passkey project
4. Set up your Corbado account and project
5. Set up Next.js with NextAuth.js
6.1. Create custom login page for NextAuth.js providers
6.2. Embed the passkey authentication webcomponent
6.4. Add Credentials Provider for the custom passkey login
7. Add passkey associate component
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):
This tutorial assumes basic familiarity with Next.js, NextAuth.js, TypeScript and HTML. Lets dive in!
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):
The application requests an association token for a certain authenticated user from our backend
Our backend requests this association token from Corbado's backend
Corbado's backend returns the token to our backend
Our backend returns the token to our frontend and the frontend hands it to
the
This enables the user to create a passkey which they can then use to login as follows:
Login with a passkey (5-9):
After the user has entered his email into the corbado-passkey-associate- login webcomponent, the webcomponent gets a challenge from Corbado's backend
The webcomponent sends back the challenge response to the Corbado backend
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
In the redirectPage we tell NextAuth that someone has logged in with a passkey
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.
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):
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!
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.
We now add the
'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).
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 ?? [] }, } }
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)
The
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
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!
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.
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
Recent Articles
How to Implement Passkeys in Java Spring Boot Apps
Nicolai - September 19, 2023
Tutorial: How to Add Passkeys to Node.js (Express) App
Lukas - October 16, 2023