Best Practice for Passkey Re-authentication in Account Management Flows

Hi Auth0 Team,

We have implemented passkeys for user sign-in. Previously, for sensitive account management actions (e.g., Change Email), we required users to re-enter their password. We now want to replace that verification step with passkey-based re-authentication.

Could you please advise on the recommended Auth0 approach?

  1. For passkey users, should we implement re-authentication using a WebAuthn assertion (navigator.credentials.get) against a fresh challenge, or is there an Auth0-recommended step-up authentication flow that achieves the same result?
  2. If a fresh challenge is required:
    • Which Auth0 API should we use to generate the challenge?
    • We need to provide the Application (Client) ID when generating the challenge. What clientId should that be, and what extra config do we need for that app to do so?
  3. After the UI returns the WebAuthn assertion response:
    • Which Auth0 API should be used to verify it?
    • What validation steps are expected on the backend?
  4. Are there any Auth0 best practices for enabling passkey verification before sensitive account management operations?

We’re looking to align with industry best practices for passkey-based re-authentication and would appreciate any recommended implementation flow.

Current implementation: We have an SPA application in the UI, and the change email operation is performed by our backend API using Auth0 management APIs

Thanks!

Hi @arpita.vyas23

I understand that you are having issues regarding the best practice of user re-authentication/step-up authentication using passkeys inside your SPA ( Singe Page App) application.

For a modern, secure, and phishing-resistant architecture, you should strictly avoid handling raw WebAuthn challenges, assertions (navigator.credentials.get), and cryptographic validations manually in your frontend or backend. Handling these APIs manually is prone to implementation vulnerabilities, especially regarding origin binding, replay attacks, and token validation.

Auth0 provides an out-of-the-box, OIDC-native Step-Up Authentication flow that manages the passkey WebAuthn ceremony securely on its hosted pages and issues a cryptographically signed cryptographic token (Access/ID Token) containing the proof of re-authentication. The documentation about Adding Step Up Authentication is available on our website.

[RECOMMENDED APPROACH]

Instead of manual WebAuthn calls , you should use the standard OIDC Step-Up Re-authentication pattern using the standard parameters max_age=0 (to force a fresh challenge) and/or acr_values (to require a specific authentication strength, like MFA or Passkeys).

Why this is preferred:

  1. No Raw Key Handling: Your SPA and backend never handle raw WebAuthn assertions, removing the risk of client-side tampering .

  2. No Cryptographic Math on Backend: Auth0 does the heavy cryptographic validation. Your backend simply validates a standard Auth0-signed JWT.

  3. Phishing Resistance: Auth0 handles the Relying Party ID (RP ID) binding securely.

When a user in your SPA clicks “Change Email”, your SPA must request a fresh, highly-assured Access Token.

Step A: Initiate Re-authentication from the SPA

  1. Call your Auth0 SPA SDK’s login method (e.g., loginWithRedirect or getTokenSilently with popup fallback), passing parameters to force fresh, high-assurance authentication
// SPA code when user clicks "Change Email"
auth0Client.loginWithRedirect({
  authorizationParams: {
    max_age: 0, 
    acr_values: 'http://schemas.openid.net/pape/policies/2007/06/multi-factor',
    scope: 'openid email update:current_user_email',
    redirect_uri: 'https://myapp.com/settings/callback'
  }
});

  1. Application (Client) ID: Use your existing SPA Client ID. You do not need to create a new client or configure a separate app.

  2. Extra Config Required: In the Auth0 Dashboard, navigate to your Custom API settings (the audience your SPA is calling) and ensure that the scope update:current_user_email is defined. Also, ensure you have enabled Passkeys (FIDO2 WebAuthn) as an authentication factor under Security > Multi-Factor Auth.

Once the authentication completes, the SPA receives a fresh Access Token. The SPA sends this Access Token in the Authorization: Bearer <TOKEN> header of the “Change Email” request to your Backend API .

Verification steps your Backend API must perform:

  1. Standard JWT Validation: Verify the token’s signature, issuer (iss), and audience (aud) using your standard Auth0 public keys.

  2. Check the auth_time Claim: Ensure the token contains an auth_time claim. Calculate current_time - auth_time. Since you passed max_age: 0, the auth_time must be less than 60–120 seconds ago. This proves the passkey was tapped just-in-time for this transaction, not hours ago.

  3. Verify amr (Authentication Methods Reference): Look at the amr array in the token . It must contain mfa and webauthn (or fido), proving a biometrics/passkey authentication took place, not a standard password.

  4. Scope Check: Ensure the token carries the update:current_user_email scope.

If all four conditions pass, your Backend API is cryptographically assured that the current user physically tapped their passkey seconds ago. It can now safely call the Auth0 Management API to update the user’s email.

Industry Best Practices for Passkey Re-authentication

  • Graceful Fallbacks (The “MFA Coop”): If a user originally signed up with a password and has not enrolled a passkey yet, the step-up flow should gracefully route them to enroll a Passkey first, or fall back to their registered MFA device (e.g., authenticator app/SMS).

  • Never handle WebAuthn state on the Backend: Do not attempt to store WebAuthn challenges in your Postgres/Redis database or write custom cryptographic verification scripts. Offloading this to Auth0 guarantees compliance with WebAuthn L2/L3 security specs.

  • Avoid Silent Tokens for Sensitive Routes: Ensure your API route ignores any Access Tokens retrieved via silent token rotation (getTokenSilently) if they do not pass the auth_time freshness test (e.g., must be less than 5 minutes old). Always force an interactive redirect for sensitive operations.

Kind Regards,
Nik