Guides
Build guide

How to add passkeys in Next.js

Every passkey tutorial has you writing four WebAuthn route handlers. This one has you writing none, and making one near-permanent decision instead: which domain runs the ceremony.

Dharmendra Jagodana6 min read

In short

Passkeys are one of eight auth methods you toggle on in the BuildBase dashboard, so there is no WebAuthn ceremony to write in Next.js. The decision that does fall to you is which domain hosts your auth pages: WebAuthn binds every passkey to that hostname, and changing it later stops existing passkeys working.

The passkey tutorials all teach the same four route handlers: registration options, registration verify, authentication options, authentication verify. They are not wrong. But on BuildBase you will write none of them, and the thing that actually costs you something is a decision those tutorials never raise.

Which domain runs the ceremony.

WebAuthn binds a credential to the hostname it was created on. Not to your app, not to your API, to the exact host the browser saw when the user touched the sensor. Get that wrong and you find out months later, when you move your auth pages and every passkey your users registered stops being offered at once.

Before you start

A BuildBase organization with passkeys enabled, and your auth domain settled before anyone registers a credential. The redirectUrl below has to be registered in your client's redirect URLs as a full URL, same as every other method. Testing your app on localhost is fine: the ceremony runs on the auth domain, not on your app, so localhost never ends up inside a passkey.

What you are building

A sign-in button that offers a passkey to people who already have one, and a settings screen where they can see what they have and remove what they do not recognise. When it works, a returning user taps sign in, the browser offers a credential it is already holding, and they are back in your app without a password field ever rendering.

The Next.js code is short. Most of this guide is the part that is not code.

Step one: turn passkeys on

Passkeys are one of the eight auth methods in the BuildBase dashboard, and enabling them is a toggle rather than a code change. The hosted portal renders whichever methods your organization has on, which is why there is no signInWithPasskey() in the SDK. There is one signIn(), and the portal decides what to offer.

Under the hood the server runs @simplewebauthn/server v13. You never import it. Worth knowing it is there the first time a WebAuthn error turns up in your logs.

What you should see: open your auth portal URL in a private window and the sign-in screen offers a passkey option alongside whatever else you have enabled.

Step two: settle the auth domain before anyone enrols

This step is out of order in every other guide, because in every other guide you own the ceremony and can move it later. Here you do not, and that is mostly a good thing.

BuildBase runs the ceremony on your organization''s auth domain, either your custom domain or the platform default. The relying party ID is that hostname, read straight off the auth base URL. So the credential your user creates says, permanently, that it belongs to that host.

Change the auth domain afterwards and the browser will not offer those credentials, because as far as it is concerned they belong to a different site. The server expects this rather than pretending otherwise. Every passkey stores the rpId it was registered under, and when your app lists a user's passkeys each one comes back with an active flag comparing its stored rpId against the current auth domain. An inactive passkey is not corrupt. It is a perfectly good credential for a domain you stopped using.

Key takeaway

Pick the auth domain you intend to keep before you let anyone register a passkey. It is the one auth decision here that is genuinely expensive to reverse, because reversing it means asking every user to enrol again.

Moving from the platform default to a custom domain counts as a change. If you want your own auth domain, set it up and get it verified before you turn passkeys on, not after your first users have enrolled.

Step three: wire the provider and the sign-in call

Nothing here is passkey-specific, which is the point.

// app/providers.tsx
'use client';

import { ApiVersion } from '@buildbase/sdk';
import { SaaSOSProvider } from '@buildbase/sdk/react';

export default function Providers({ children }) {
  return (
    <SaaSOSProvider
      serverUrl="https://api.yourapp.com"
      version={ApiVersion.V1}
      orgId="your-org-id"
      auth={{
        clientId: 'pk_demo_placeholder',
        redirectUrl: 'http://localhost:3000/callback',
        callbacks: {
          getSession: async () => localStorage.getItem('sessionId'),
          handleAuthentication: async (code) => {
            const res = await fetch('/api/auth/verify', {
              method: 'POST',
              body: JSON.stringify({ code }),
            });
            const data = await res.json();
            return { sessionId: data.sessionId };
          },
          onSignOut: async () => localStorage.removeItem('sessionId'),
        },
      }}
    >
      {children}
    </SaaSOSProvider>
  );
}

Then the button:

'use client';

import { useSaaSAuth } from '@buildbase/sdk/react';

export default function SignInButton() {
  const { signIn, isLoading, isAuthenticated } = useSaaSAuth();

  if (isLoading) return <Spinner />;
  if (isAuthenticated) return null;

  return <button onClick={() => signIn('/dashboard')}>Sign in</button>;
}

localStorage is in that snippet because it is one line and it reads clearly. It is also readable by any script on your page, so if the app touches billing or personal data, move the session ID to an HttpOnly cookie set by /api/auth/verify and have getSession read it server-side.

What you should see: a click takes you to the portal, the browser raises its own passkey prompt, and you come back to /dashboard with isAuthenticated true.

Step four: give people somewhere to manage them

Passwords have a reset flow. Passkeys have a list, and a user who has lost a device needs to reach it.

'use client';

import { useSaaSAuth } from '@buildbase/sdk/react';

export default function SecurityLink() {
  const { openWorkspaceSettings } = useSaaSAuth();

  return (
    <button onClick={() => openWorkspaceSettings('security')}>Security</button>
  );
}

The security screen shows each passkey a user holds, flags any registered on an old auth domain as inactive, and lists the sessions they are signed in on. Adding one happens at sign-in. If you are building your own UI instead, the session-authenticated endpoints under /api/v1/public/passkeys list, rename and remove.

Registration is deliberately not among them. A registration call made from your app's domain would mint a credential bound to your app rather than to your auth domain, which is the failure in step two arriving by a side door, so enrolment stays on the hosted pages.

The failure modes

The prompt expires. The challenge is stored for 5 minutes and consumed atomically, so it answers exactly once. Someone who starts signing in, gets pulled into a meeting and comes back at minute eight sees an expired-challenge error. There is nothing to fix and nothing to log. Just make the retry obvious rather than showing a generic failure.

A user cannot tell two of your workspaces apart. On the shared auth domain every organization uses the same relying party, so someone who belongs to two of them would otherwise see two identical rows in the browser's passkey picker, both reading their email address. Passkeys are therefore labelled with a real host taken from the organization's redirect URLs, which turns the picker entry into [email protected] · app.example.com. If your redirect URLs are all localhost, private network addresses or wildcards there is no host worth showing, and the label falls back to the bare email. That is a reason to register your production URL early rather than the week you launch.

The domain moved and nobody said so. Covered above, but it is worth being concrete about the user's experience: they open sign-in, the browser offers nothing, and there is no error because from the browser's point of view nothing went wrong. The pre-built security screen marks those passkeys inactive; if you built your own, read the active flag and do the same rather than leaving people to guess.

User verification is preferred, not required. A device without biometrics can complete the ceremony on presence alone. So a successful passkey sign-in proves possession of the device, and does not by itself prove who was holding it. Worth knowing before you describe the property to anyone.

What this does not give you

A passkey replaces the password. It does not stack on top of one: BuildBase does not ship MFA, so if a security review asks for a second factor, passkeys are not the answer to hand them. What they do give you is phishing resistance, which is a different property and often the more useful one, because there is no code for anyone to talk a user into reading out.

Sessions behave the same as with any other method: 30 days by default, capped at 180 days. Permissions still refresh hourly rather than instantly, which catches people out across every auth method and has its own guide.

If you want the convenience without the hardware, magic link is the other passwordless method in the same toggle list, and a user can have both. If the redirect comes back and bounces you straight to sign-in again, that is the middleware redirect loop rather than anything to do with passkeys.

Install

npm i @buildbase/sdk
auth
nextjs
passwordless

Frequently Asked Questions

Do I need to write WebAuthn route handlers in Next.js?

No. The four handlers most passkey tutorials build - registration options, registration verify, authentication options, authentication verify - all run on your organization's auth domain, your custom domain or the platform default. Your Next.js app calls the same signIn() it would for any other method, and the hosted portal renders whichever methods your organization has enabled.

What happens to existing passkeys if I change my auth domain?

They stop working. WebAuthn binds a credential to the hostname of the ceremony that created it, so a passkey registered on one auth domain is not offered by the browser on another. Every passkey stores the rpId it was registered under, and the list endpoint returns an active flag comparing that to your current auth domain. Settle the domain before anyone enrols.

Can a user register a passkey from my own app domain?

No, and that is deliberate. Registration only happens on the hosted auth pages, because a ceremony run on your app domain would mint a credential bound to your app rather than to your auth domain. Your app can list, rename and remove passkeys through session-authenticated endpoints, but not create them.

How long does a user have to complete a passkey prompt?

Five minutes. The challenge is stored with a 300-second TTL and consumed atomically, so it answers exactly once. A user who starts signing in, gets distracted and comes back later sees an expired-challenge error and simply tries again.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime