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.
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.
Related
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