Building Embedded Passkey Login with Auth0
Auth0's Universal Login is the hosted, redirect-based flow behind most Auth0 integrations, and for good reason - it's maintained for you, it picks up new authentication methods automatically, and for the majority of applications it's the right default. Alongside it, Auth0 also ships Passkey APIs that let you build passkey enrolment and sign-in directly into your own UI, using the browser's native WebAuthn API, with no redirect at all.
I put both patterns side by side in a small Next.js demo, using @auth0/nextjs-auth0 for the SDK layer on each. In this post I will detail how the embedded, in-app passkey flow fits next to Universal Login, what's needed to get WebAuthn's origin requirements working with a custom domain, and how the same self-service passkey management looks built by hand against the My Account API versus using Auth0's official pre-built React components.
The steps we will cover in this post are as follows.
- Universal Login vs embedded login - when each one earns its keep
- Setting up a custom domain so WebAuthn's origin rules actually line up
- Wiring up the embedded passkey sign-in flow
- Self-service passkey management: hand-rolled vs Auth0's pre-built components
- One setting worth checking before you assume it's a WebAuthn bug
Universal Login vs Embedded Login
Universal Login and embedded login aren't really competing options - they're suited to different moments in the same journey. Universal Login is the obvious choice for primary sign-in: it's a hosted page Auth0 maintains, it inherits new login methods without you touching your own code, and it centralises session and SSO handling across every app in your tenant. Embedded login earns its place for the moments where leaving your UI is the wrong experience - step-up re-authentication mid-flow, or an in-app login screen for a product where the redirect hop genuinely costs you something. Auth0's own Passkey APIs are built for exactly that second case: a POST to a passkey challenge endpoint, a call to navigator.credentials.get() in the browser, and a POST to /oauth/token using the urn:okta:params:oauth:grant-type:webauthn grant to finish the exchange - the okta namespace is intentional, a reminder that Auth0 and Okta share plumbing under the hood - all without the page ever navigating away.
The trade-off is that embedded login shifts real infrastructure work onto you, and the clearest example of that is WebAuthn's origin requirements.
Setting Up a Custom Domain for WebAuthn
WebAuthn ties a passkey to a relying party ID, and that ID has to match, or be a registrable suffix of, the origin the ceremony actually runs from. Auth0's hosted Universal Login pages are served from Auth0's own domain, so this is automatically satisfied. An embedded flow runs from your application's own origin, so if your app and your Auth0 tenant don't share a domain suffix, the browser will refuse the ceremony before it even reaches Auth0.
The fix is a custom domain that shares a root with your application, split across two subdomains - in this demo, auth.tobytesdemo.com for the Auth0-managed custom domain and app.tobytesdemo.com for the application itself. Set the relying party ID to the shared apex, tobytesdemo.com, and both subdomains satisfy WebAuthn's origin check. Once that's live, a single passkey enrolled from one subdomain verifies cleanly from the other.
Wiring Up the Embedded Passkey Sign-In Flow
This is where @auth0/nextjs-auth0 does more than the docs let on. The SDK ships a one-call browser client, passkey.login(), and matching server-side methods that its own middleware auto-mounts as /auth/passkey/* routes - no hand-rolled Server Actions calling Auth0's raw endpoints required:
import { passkey } from "@auth0/nextjs-auth0/client";
async function signInWithPasskey() {
await passkey.login({ connection: "Username-Password-Authentication" });
}For enrolment, the same pattern applies on the server side - auth0.passkey.enrollmentChallenge() and enrollmentVerify() handle the actual POST calls to the My Account API, so a component only needs to bridge the browser's navigator.credentials.create() call between the two.
Self-Service Passkey Management: Hand-Rolled vs Pre-Built Components
Once a user has a passkey, they need somewhere to manage it - add a new one, remove an old device, check what's enrolled. I built this two ways in the same demo, on the same tenant, to compare them directly.
The first is hand-rolled against the My Account API: a page listing /me/v1/authentication-methods, backed by Server Actions for add and remove, with the browser-side WebAuthn encoding written out explicitly. It's more code, but every request and every field is under your control.
The second uses Auth0's official Universal Components - UserPasskeyManagement and UserMFAManagement from @auth0/universal-components-react - dropped straight into the page:
<UserPasskeyManagement />
<UserMFAManagement />Both talk to the same My Account API underneath, and both work. The pre-built components get you there with almost no code and consistent styling out of the box; the hand-rolled version costs more upfront but gives you full control over the request shape and the UI. Which one is right depends entirely on how much of that control you actually need.
One Setting Worth Checking
While building the embedded sign-in flow, I hit a passkey login that failed at the token exchange with a generic invalid_grant / invalid passkey error, despite the same credential working perfectly through Universal Login and enrolment. The eventual fix was a single Auth0 application setting: Allowed Origins (CORS) didn't include the origin the embedded login page was actually served from.
It's an easy one to miss because "Allowed Origins (CORS)" reads as a browser-only concern, and the passkey challenge and token calls in this flow are made server-side by the SDK's own routes - so a classic CORS preflight error never appears in the browser console to point you at it. What actually happens is that a WebAuthn assertion's clientDataJSON records the origin the ceremony ran from, and Auth0 validates that origin server-side during the webauthn grant against the same Allowed Origins list. Leave your embedded login page's origin off that list and Auth0 rejects the assertion - worth adding to your setup checklist alongside the custom domain and RP ID configuration above, right from the start.
Get Started
Between Universal Login for primary sign-in and the embedded Passkey APIs for the moments a redirect doesn't fit, Auth0 gives you a genuinely complete passkey story without having to hand-roll WebAuthn from scratch. Start with the Passkey APIs documentation if you're wiring up embedded sign-in, or the Universal Components overview if self-service management is the more immediate need.