Blog · Guides

How to add passkey login to Next.js

Add passkey login to a Next.js app with Auth.js and standard OpenID Connect: PKCE, no client secret, protected routes and sign-out, step by step with code.

The quickest way to add passkey login to a Next.js app is to let a hosted passkey provider run the WebAuthn ceremony and connect to it with Auth.js as a standard OpenID Connect provider. You add one provider entry to auth.ts, expose the Auth.js route handlers, register the callback URL /api/auth/callback/gatekeep, and call signIn("gatekeep"). Users are sent to a sign-in page where they use Face ID, Touch ID, Windows Hello or a security key, and come back with a session. There’s no WebAuthn code in your app and no client secret.

This guide uses Next.js with the App Router and Auth.js v5 (the next-auth@beta package). It takes about fifteen minutes.

What you’ll build

  • A sign-in button that sends users to a hosted passkey page.
  • A session that holds the user’s stable ID (sub) and verified email.
  • A protected page that redirects signed-out users.
  • A sign-out button.

Under the hood, Auth.js runs the OpenID Connect authorization code flow with PKCE. If you want the details, read OAuth PKCE explained.

Before you start

You need:

  • A Next.js app using the App Router.
  • An issuer URL and client ID for your app. With Gatekeep you get these when you register an app. The issuer looks like https://acme.gatekeepauth.com.
  • Your redirect URIs registered with the provider:
http://localhost:3000/api/auth/callback/gatekeep
https://app.yourapp.com/api/auth/callback/gatekeep

Auth.js always uses /api/auth/callback/<provider id>, so the provider ID you choose in config (gatekeep here) must match the path.

Step 1: Install Auth.js

npm install next-auth@beta
npx auth secret   # writes AUTH_SECRET to .env.local

AUTH_SECRET encrypts Auth.js’s own session cookie. It’s local to your app and has nothing to do with the identity provider; there’s still no client secret.

Add your issuer and client ID to .env.local:

GATEKEEP_ISSUER=https://acme.gatekeepauth.com
GATEKEEP_CLIENT_ID=acme-notes

Step 2: Configure the provider

Create auth.ts in the root of your project:

import NextAuth from "next-auth"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "gatekeep",
      name: "Gatekeep",
      type: "oidc",
      issuer: process.env.GATEKEEP_ISSUER,
      clientId: process.env.GATEKEEP_CLIENT_ID,
      // Public client: PKCE instead of a client secret.
      client: { token_endpoint_auth_method: "none" },
      checks: ["pkce", "state", "nonce"],
      authorization: { params: { scope: "openid email" } },
    },
  ],
  callbacks: {
    // Keep the provider's permanent user ID on the session.
    session({ session, token }) {
      if (token.sub) session.user.id = token.sub
      return session
    },
  },
})

What each option does:

OptionWhy
type: "oidc"Auth.js reads the discovery document from the issuer and validates the ID token.
client.token_endpoint_auth_method: "none"Tells Auth.js this is a public client, so it doesn’t send a secret to the token endpoint.
checks: ["pkce", "state", "nonce"]Enables PKCE, CSRF protection on the callback, and ID token replay protection.
scope: "openid email"Requests an ID token with the user’s email and email_verified claims.

Auth.js fetches the JWKS and checks the ID token’s signature, issuer, audience and expiry for you.

Step 3: Expose the route handlers

Create app/api/auth/[...nextauth]/route.ts:

import { handlers } from "@/auth"
export const { GET, POST } = handlers

This serves /api/auth/signin, /api/auth/callback/gatekeep, /api/auth/signout and the other Auth.js endpoints.

Step 4: Add sign-in and sign-out buttons

Server actions keep this short:

// components/auth-buttons.tsx
import { signIn, signOut } from "@/auth"

export function SignIn() {
  return (
    <form action={async () => { "use server"; await signIn("gatekeep") }}>
      <button type="submit">Sign in with a passkey</button>
    </form>
  )
}

export function SignOut() {
  return (
    <form action={async () => { "use server"; await signOut() }}>
      <button type="submit">Sign out</button>
    </form>
  )
}

Clicking Sign in with a passkey redirects to the hosted sign-in page. New users create an account there, verifying their email once, and returning users unlock their passkey. Then they’re redirected back to your app.

Step 5: Read the session and protect pages

In any server component:

// app/dashboard/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"

export default async function Dashboard() {
  const session = await auth()
  if (!session?.user) redirect("/")

  return <p>Signed in as {session.user.email} (id {session.user.id})</p>
}

Use session.user.id, the provider’s sub claim, as the key for the user in your database. Emails can change; sub doesn’t.

To protect many routes at once, you can export auth as middleware and match the paths you want gated. See the Auth.js documentation for the current middleware conventions in your Next.js version.

Step 6: Sign out everywhere (optional)

signOut() clears your app’s session cookie. The user is still signed in at the identity provider, which is what makes single sign-on across your apps work. If you want a full sign-out, also send the user to the provider’s end_session_endpoint:

const url = new URL("/logout", process.env.GATEKEEP_ISSUER)
url.searchParams.set("post_logout_redirect_uri", "https://app.yourapp.com/")
// redirect(url.toString()) after clearing the local session

The trade-offs are covered in single sign-on across your own apps and in the SSO and logout docs.

Testing locally

Run npm run dev and open http://localhost:3000. Because the WebAuthn ceremony happens on the provider’s sign-in page, not on localhost, passkeys work locally without any HTTPS setup on your side. Make sure the localhost callback URL is registered.

Passkeys are bound to the domain they’re created on. If you plan to run sign-in on your own domain, set that up before inviting real users so they don’t have to enroll again later.

Troubleshooting

  • redirect_uri mismatch: the registered URI must match exactly, including scheme, port and the /api/auth/callback/gatekeep path.
  • Missing email: check that the scope includes email.
  • invalid_client at the token step: make sure token_endpoint_auth_method is "none" so Auth.js doesn’t try to authenticate with a secret.

Next steps with Gatekeep

That’s passkey login in Next.js with nothing proprietary in your code: Auth.js and standard OIDC. The same setup works for any other app you register, and users who’ve signed in to one are signed in to the rest. See the Next.js docs page, the quickstart, and how to verify tokens in your API. Gatekeep is in early access: request access here.

Delete the password field.

Passkey sign-in for every app you ship. Free up to 5,000 monthly active users.