Toby Allen

Embedded Passkey Step-Up, and Forcing User Verification Auth0 Won't Let You Configure

· Auth0, passkey, MFA, Identity Security, Customer Identity Products

I wrote previously about building embedded passkey login into a small Next.js demo using @auth0/nextjs-auth0, as a contrast to Auth0's hosted Universal Login. The same demo has since grown two more features worth their own write-up: embedded step-up authentication, where a user already signed in has to prove their identity again with a passkey before seeing something sensitive, and a second passkey button that forces WebAuthn's user verification requirement to something Auth0 doesn't let you configure anywhere.

Both features sit in the same part of the stack - the manual WebAuthn ceremony this demo already runs instead of Auth0's one-call passkey.login() - but they surfaced two very different kinds of problem. The first is an authorisation gap in how Auth0's own passkey verification endpoint behaves. The second is a protocol-level setting with no dashboard equivalent I could find. In this post I will detail both: how I built and then rebuilt the step-up design after two separate reviews found two separate ways the first version was broken, and how a single userVerification field, overridden client-side, is the only way I found to force and then confirm that an authenticator actually verified the user, rather than relying on preferred and taking it on faith.

Step-Up Authentication Needs More Than a Passkey Prompt

Step-up authentication is the pattern where a user is already signed in, but a specific action - viewing a sensitive record, approving a payment, changing a security setting - demands a fresh, stronger proof of identity before it proceeds. Auth0's own passkey ceremony is a natural fit for this: the same /passkey/challenge request, navigator.credentials.get() call, and urn:okta:params:oauth:grant-type:webauthn token exchange that embedded login uses, run again, in-page, right before the sensitive action.

The problem is that Auth0's SDK-mounted passkey verification route, /auth/passkey/get-token, verifies the WebAuthn signature and nothing else. It has no concept of "does this match who was already logged in" - present it with any valid passkey assertion and it will happily overwrite the current session with whoever that passkey belongs to. That's exactly correct behaviour for a login route. It's a serious problem for a step-up route, where the entire point is confirming the existing user, not silently letting a different passkey holder take over their session.

I planned this feature entirely in plan mode before writing any code, and before finalising it I ran the plan through two adversarial reviews on the internal LiteLLM proxy, deliberately using a different model provider for each round - OpenAI's gpt-5.4 first, then Google's gemini-3.1-pro-preview - specifically so one provider's blind spots weren't reviewing its own family's reasoning. The first round caught the missing identity check described above, plus a second flaw: my fix called the SDK-mounted route via a server-to-server fetch() rather than letting the browser call it directly, which meant the SDK's own session-cookie mutation landed on the internal fetch response and never reached the browser at all.

I fixed both and moved to round two. What round two found wasn't a new flaw in the design - it was that my round-one fix for the identity check didn't actually run. The fix compared session.user.sub before and after the internal fetch call, re-reading the session a second time in the same route handler. But Next.js's cookies() helper, which auth0.getSession() falls back to whenever no explicit request object is passed, only ever reflects the incoming request's cookies - never anything appended to an outgoing response mid-handler. So the second read would always silently return the original session, making the before/after comparison a no-op by construction. I confirmed this directly against the SDK's own getSession() source rather than taking the review's word for it.

The actual fix ended up architecturally different from either review round's framing, and better for it: call Auth0's /oauth/token endpoint directly with the WebAuthn grant, authenticated with this app's own client secret over a server-to-server HTTPS call, then validate the returned ID token and compare its sub claim to the already-logged-in session user, before that token ever touches the SDK's session store at all. Only on a match does the route write anything - a separate, app-owned step-up assurance cookie (HMAC-signed, short-lived, scoped to the session and the specific action it's gating), never the SDK's own session. That design doesn't depend on session-cookie propagation within a single request at all, which made the first round's Set-Cookie-forwarding concern moot as a side effect rather than something that needed its own patch.

Not every review finding held up on inspection. The second round also claimed this flow would silently lose the refresh token, because the WebAuthn grant supposedly omits the offline_access scope. I checked that against the SDK source directly rather than accepting it: the passkey grant's scope comes from the same static authorizationParameters.scope the whole Auth0Client instance is constructed with, so that claim was simply wrong. A separate, smaller finding from the same round did hold up - the redirect/popup-based comparison path for the same step-up feature, using Auth0's standard max_age=0 plus challengeMode=popup pattern, had the identical unchecked-identity problem via a different mechanism: the SDK's mergePopupTokenIntoSession helper unconditionally merges the popup's ID token claims onto the existing session rather than checking they belong to the same user. Two claims from the same review round, right next to each other - one correct, one not. "An AI review flagged issues" isn't a meaningful unit on its own; each claim earns scrutiny independently of how it arrived.

The Setting Auth0 Doesn't Expose Anywhere

The second feature started from a much simpler observation: Auth0's Passkey APIs documentation shows exactly what a passkey challenge response looks like, and both the signup and login examples set "userVerification": "preferred".

userVerification is a field on the PublicKeyCredentialRequestOptions object passed to navigator.credentials.get(), and it tells the authenticator how strongly to insist on verifying the user as part of the ceremony - a PIN, a fingerprint, a face scan - rather than just confirming someone is physically present and tapped a button. The WebAuthn spec defines three values: required, preferred, and discouraged. preferred asks the authenticator to verify the user if it can, but a successful assertion can still come back without verification having happened at all - the client and authenticator are free to skip it. Auth0's own sample code even comments preferred as "Try to use UV if possible," which is the whole gap in one phrase. For primary login that's a defensible default: you don't want to lock out a user whose authenticator genuinely can't verify. For a step-up or reauthentication ceremony gating something sensitive, "the authenticator was allowed to skip verification" is precisely the gap you're trying to close.

I couldn't find a documented way to change this. I checked both the Passkey APIs page and the Configure Passkey Policy page - the latter covers enabling passkeys, the authentication UI mode, progressive and local enrolment, and relying party ID configuration, but nothing about userVerification. No Dashboard toggle, no Management API field. In my testing, preferred is simply what Auth0's challenge endpoint returns, every time.

The practical place to change it is in your own code, right before the browser's WebAuthn call. userVerification lives on the decoded PublicKeyCredentialRequestOptions object, which this demo already builds by hand rather than handing straight to navigator.credentials.get() - a decision I made for an unrelated reason back when I first built the embedded login flow, to work around an id-encoding bug in Auth0's own one-call client. That same manual decode step is exactly where a client-side override belongs:

const requestOptions = decodeRequestOptions(challenge.authnParamsPublicKey);
if (forceUserVerification) requestOptions.userVerification = "required";
 
const credential = await navigator.credentials.get({ publicKey: requestOptions });

Overriding the field is only half the feature. The other half is proving the override actually did something, rather than just asserting it in prose. WebAuthn authenticator data carries a flags byte with a UV bit that records whether user verification was performed for that specific assertion - it doesn't say how (PIN, fingerprint, face scan all set the same bit), but it does say whether the authenticator did it at all. This demo already decoded that byte for an entirely different diagnostic purpose - working out whether a passkey was backup-eligible and currently backed up. The userVerified flag was sitting right there, decoded and unused. Wiring it into the same debug panel the demo already shows after every login turned the feature from a claim into something you watch happen: two buttons side by side, one requesting preferred and one forcing required, each showing its own userVerified: true or false straight out of the authenticator's response. Under required, an authenticator that can't satisfy verification at all should fail the ceremony outright rather than return false - which authenticator you're testing with changes which of those two outcomes you'll actually see.

The same decode step, now feeding both features, is shown below.

One manual WebAuthn decode step feeding both the step-up identity check and the forced userVerification check

Final Thoughts

Both features ended up depending on a decode step I only wrote to get around an unrelated bug in Auth0's own client. I'd like to claim that was foresight. It wasn't - I just hadn't thrown the code away yet. If you're building your own embedded passkey flow and want to force verification on a step-up path, the fix is the same regardless of SDK: decode or build the request options yourself, and set userVerification before they reach navigator.credentials.get().