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:
| Option | Why |
|---|---|
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.
Troubleshooting
redirect_urimismatch: the registered URI must match exactly, including scheme, port and the/api/auth/callback/gatekeeppath.- Missing
email: check that the scope includesemail. invalid_clientat the token step: make suretoken_endpoint_auth_methodis"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.