AuthLabX
Log in
POC-001Intermediate22 min

Self-service MFA & passkeys with the My Account API

Let signed-in users enroll, rename and remove passkeys, authenticator apps, security keys, SMS, email and recovery codes from your own profile page, using Auth0's user-scoped /me/v1 API through a Next.js backend-for-frontend.

API
My Account API · /me/v1
Audience
https://{domain}/me/
Stack
Next.js 16 · nextjs-auth0 v4
Factors
passkey · webauthn · totp · push · phone · email · recovery

§ 01what & why

Overview

Most products need a Security page where users add a passkey, set up an authenticator app or remove a lost phone. Before the My Account API, that meant either sending users to a hosted page or calling the Management API with powerful admin credentials from your backend.

The My Account API is a user-scoped API on your Auth0 domain at /me/v1. The access token belongs to the signed-in user, so it can only ever touch that user's account. No admin secret is involved, and Auth0 enforces the permissions with ordinary OAuth scopes such as create:me:authentication_methods.

What you get from this POC

  • A working Profile → Security page: list, enroll, rename and remove every factor type.
  • A backend-for-frontend (BFF) so the My Account token never reaches JavaScript in the browser.
  • The full WebAuthn ceremony for passkeys and security keys, including the base64url conversions.
  • Developer mode: every HTTP call to Auth0, with request and response, shown live in the UI.
  • A demo mode that runs the same UI against a contract-faithful mock, with no tenant needed.

When to choose which tool

OptionWho actsUse it when
My Account APIThe user, with their own tokenYou want the UI inside your product, with your design, and per-user least privilege.
Universal PortalsThe user, on a hosted pageYou prefer an Auth0-hosted self-service portal over building UI.
Management APIYour backend, as adminSupport or admin tooling acting on other users (reset MFA, send enrollment tickets).

§ 02backend-for-frontend

Architecture

The browser never talks to Auth0's /me/v1 directly. It calls our route handlers under /api/my-account/*. They read the encrypted session cookie, get a valid My Account access token (refreshing it if needed) and forward the call. The one step that must run in the browser is the WebAuthn ceremony, because only the browser can talk to the authenticator.

Architecture: the token never leaves the server
server side, holds tokensBrowserReact UI · /profile/securityfetch /api/my-account/*navigator.credentialsHolds only an encrypted,httpOnly session cookieNext.js BFFRoute handlers + @auth0/nextjs-auth0auth0.getAccessToken()LiveMyAccountClientSession · refresh token ·My Account access tokenAuth0 tenantsame domain everywhere/authorize · /oauth/token/me/v1/…aud: https://{domain}/me/scopes: *:me:* (user grant)cookieBearerrefresh

Each AuthLabX route handler maps one-to-one onto a My Account endpoint:

BFF route map
Browser → AuthLabX BFF                                    BFF → Auth0 My Account API
GET    /api/my-account/factors                            GET    /me/v1/factors
GET    /api/my-account/authentication-methods             GET    /me/v1/authentication-methods
POST   /api/my-account/authentication-methods             POST   /me/v1/authentication-methods
POST   /api/my-account/authentication-methods/{id}/verify POST   /me/v1/authentication-methods/{id}/verify
PATCH  /api/my-account/authentication-methods/{id}        PATCH  /me/v1/authentication-methods/{id}
DELETE /api/my-account/authentication-methods/{id}        DELETE /me/v1/authentication-methods/{id}

§ 03~10 minutes

Tenant setup

You need an Auth0 tenant and the Auth0 CLI (brew install auth0/auth0-cli/auth0, then auth0 login). Use the Dashboard where a CLI command isn't shown.

  1. Custom domain (if you use one): set it up first

    Dashboard → Branding → Custom Domains. Finish DNS verification first. From then on use that domain everywhere, including the My Account audience (https://login.example.com/me/), every step below and AUTH0_DOMAIN. Section 05 shows the full flow.

    terminal
    # Your custom domain must be verified and "ready" before you use it
    auth0 domains list
    
    # Then point the app at it. Nothing else in the code changes:
    # AUTH0_DOMAIN=login.example.com
  2. Activate the My Account API

    Dashboard → Authentication → APIs. Find the My Account API banner and select Activate. This creates the system API with the identifier https://{yourDomain}/me/.

    Its default access policy is require_client_grant for user flows, so every application has to be granted access explicitly (next steps). Machine-to-machine access is denied.

  3. Create the Regular Web Application

    terminal
    auth0 apps create \
      --name "AuthLabX" \
      --type regular \
      --callbacks "http://localhost:3000/auth/callback" \
      --logout-urls "http://localhost:3000" \
      --reveal-secrets

    Copy the client ID and secret into .env.local. The app uses the authorization code flow with a client secret, so it is a confidential client.

  4. Grant the app the My Account scopes

    System APIs do not support default grants, so create a user client grant for this app:

    terminal
    auth0 api post "client-grants" --data '{
      "client_id": "<AUTHLABX_CLIENT_ID>",
      "audience": "https://<YOUR_DOMAIN>/me/",
      "subject_type": "user",
      "scope": [
        "read:me:factors",
        "read:me:authentication_methods",
        "create:me:authentication_methods",
        "update:me:authentication_methods",
        "delete:me:authentication_methods"
      ]
    }'

    Dashboard equivalent: Applications → APIs → My Account API → Application Access → Edit, then grant the scopes for user access.

  5. Enable the factors you want to offer

    GET /me/v1/factors only returns what is enabled here, and the UI adapts to it.

    terminal
    # Authenticator app (TOTP)
    auth0 api put "guardian/factors/otp" --data '{"enabled": true}'
    
    # Email codes
    auth0 api put "guardian/factors/email" --data '{"enabled": true}'
    
    # Recovery codes (strongly recommended so users can't lock themselves out)
    auth0 api put "guardian/factors/recovery-code" --data '{"enabled": true}'
    
    # SMS: three separate calls
    auth0 api put "guardian/factors/sms" --data '{"enabled": true}'
    auth0 api put "guardian/factors/phone/message-types" --data '{"message_types": ["sms"]}'
    auth0 api put "guardian/factors/phone/selected-provider" --data '{"provider": "auth0"}'
    
    # Check the result (list endpoint only)
    auth0 api get "guardian/factors"

    WebAuthn factors (security keys, device biometrics) and Guardian push are under Security → Multi-factor Auth. Guardian push also needs its delivery provider configured.

  6. Enable passkeys on the database connection

    Dashboard → Authentication → Database → your connection → Authentication Methods → Passkey. Passkeys enrolled through the My Account API are bound to that connection. If the user has several identities, pass connection (and optionally identity_user_id) in the enrollment request. Set AUTH0_PASSKEY_CONNECTION and the BFF adds it for you.

  7. Configure the app and run it

    .env.local
    AUTHLABX_DEMO_MODE=false
    AUTH0_DOMAIN=login.example.com            # login domain (custom or canonical)
    AUTH0_MY_ACCOUNT_DOMAIN=                   # leave empty: audience follows AUTH0_DOMAIN
    AUTH0_CLIENT_ID=...
    AUTH0_CLIENT_SECRET=...
    AUTH0_SECRET=...                           # openssl rand -hex 32
    APP_BASE_URL=http://localhost:3000
    AUTH0_PASSKEY_CONNECTION=Username-Password-Authentication   # optional
    terminal
    npm install
    npm run dev   # http://localhost:3000

§ 04least privilege

Scopes & tokens

ScopeUnlocks
read:me:factorsGET /factors
read:me:authentication_methodsGET /authentication-methods, GET /authentication-methods/{id}
create:me:authentication_methodsPOST /authentication-methods, POST /{id}/verify
update:me:authentication_methodsPATCH /authentication-methods/{id}
delete:me:authentication_methodsDELETE /authentication-methods/{id}

Getting the token: three options

A. My Account as the login audience (AUTH0_AUDIENCE empty). The login requests audience=https://{domain}/me/ plus the scopes, so the session's access token is the My Account token. This is the simplest option when your app has no API of its own.

src/lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";

export const auth0 = new Auth0Client({
  authorizationParameters: {
    audience: "https://" + process.env.AUTH0_DOMAIN + "/me/",
    scope: [
      "openid", "profile", "email", "offline_access",
      "read:me:authentication_methods",
      "create:me:authentication_methods",
      "update:me:authentication_methods",
      "delete:me:authentication_methods",
      "read:me:factors",
    ].join(" "),
  },
  // A custom hook disables the SDK's default claim filter, so keep an explicit
  // allow-list and opt "amr" / "acr" in: they tell us whether MFA was used.
  async beforeSessionSaved(session) {
    const user = Object.fromEntries(
      Object.entries(session.user).filter(([k]) => SESSION_CLAIMS.includes(k)),
    );
    return { ...session, user };
  },
});

B. Your own API as the login audience + MRRT (AUTH0_AUDIENCE=https://api.poc-app.local). This is the usual setup for a real product: the login token is for your API and carries none of the *:me:* scopes. When a Profile page needs the My Account API, the BFF exchanges the refresh token for a second token with the My Account audience. The SDK keeps one access token per audience in the session, so the exchange only happens again when that token expires.

src/lib/auth0.ts (option B)
export const auth0 = new Auth0Client({
  authorizationParameters: {
    audience: "https://api.poc-app.local",   // AUTH0_AUDIENCE: your own API
    // offline_access is what makes MRRT possible: no refresh token, no exchange.
    // No *:me:* scopes here; they are requested later, per audience.
    scope: "openid profile email offline_access",
  },
});
src/lib/session.ts (excerpt)
// Works for both options. With MRRT the SDK exchanges the refresh token
// for a token with this audience and caches it in the session.
const { token } = await auth0.getAccessToken({
  audience: myAccount.audience,          // https://{domain}/me/
  scope: myAccount.scopes.join(" "),
});
return new LiveMyAccountClient(token);

C. Your own API as the login audience + Custom Token Exchange (same AUTH0_AUDIENCE, switch set to CTE). The BFF exchanges the login access token instead of the refresh token, through an Action you write. Section 05 has the Action, the profile and the request.

§ 05every request, in order

Custom domain & token flow

This section follows one user from Log in to the first /me/v1 call, with the raw HTTP of each step. The examples use the custom domain login.example.com; replace it with yours. If you don't use a custom domain, the canonical tenant.eu.auth0.com works the same way.

Login → token → My Account call (custom domain)
BrowserNext.js BFFlogin.example.com…/me/v1GET /auth/login302 → /authorizeaudience=https://login.example.com/me/ + scopesGET /authorizeUniversal Login (+ MFA if your Action asks)302 → /auth/callback?code=…GET /auth/callback?code=…server to serverPOST /oauth/tokenauthorization_code + client_secret + code_verifieraccess_token (aud …/me/), refresh_token, id_tokenstored in the encrypted __session cookieevery Security page callGET /me/v1/authentication-methodsAuthorization: Bearer <access_token>200 { authentication_methods: […] }
  1. The app redirects to /authorize on the custom domain

    nextjs-auth0 builds this request from AUTH0_DOMAIN and authorizationParameters. The audience is the custom domain plus /me/, with the trailing slash.

    GET /auth/login → 302
    # 1. Browser → GET /auth/login (our app). The SDK answers with a redirect:
    HTTP/1.1 302 Found
    Location: https://login.example.com/authorize
      ?response_type=code
      &client_id=<AUTH0_CLIENT_ID>
      &redirect_uri=http://localhost:3000/auth/callback
      &audience=https://login.example.com/me/          # ← custom domain + /me/
      &scope=openid profile email offline_access
             read:me:authentication_methods create:me:authentication_methods
             update:me:authentication_methods delete:me:authentication_methods
             read:me:factors
      &state=<random>&nonce=<random>
      &code_challenge=<S256(code_verifier)>&code_challenge_method=S256
  2. Auth0 sends the user back with a code

    The user signs in on Universal Login. If a post-login Action asks for MFA (see step-up), that happens here too.

    302 → callback
    # 2. User signs in on Universal Login (login.example.com), then:
    HTTP/1.1 302 Found
    Location: http://localhost:3000/auth/callback?code=<authorization_code>&state=<random>
  3. The server exchanges the code for tokens

    This happens inside /auth/callback on our server. It uses the client secret and PKCE verifier, and it goes to the same domain as /authorize.

    POST /oauth/token (authorization_code)
    # 3. Server → Auth0 (never from the browser). Same custom domain.
    POST https://login.example.com/oauth/token
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=authorization_code
    &client_id=<AUTH0_CLIENT_ID>
    &client_secret=<AUTH0_CLIENT_SECRET>
    &code=<authorization_code>
    &redirect_uri=http://localhost:3000/auth/callback
    &code_verifier=<code_verifier>
    
    # ← 200
    {
      "access_token": "eyJhbGciOi…",     // the My Account token
      "refresh_token": "v1.MjQ…",        // because offline_access was requested
      "id_token": "eyJhbGciOi…",
      "scope": "openid profile email offline_access read:me:authentication_methods …",
      "expires_in": 86400,
      "token_type": "Bearer"
    }
  4. Check the token claims

    Auth0 sets iss to the domain you used for the token request. aud must name the same domain you will call. Paste a token into jwt.io (dev tenants only) to check it.

    access token payload
    // 4. Decoded access token: check these three claims first when debugging
    {
      "iss": "https://login.example.com/",               // domain used for /oauth/token
      "aud": [
        "https://login.example.com/me/",                 // must match the domain you call
        "https://login.example.com/userinfo"
      ],
      "scope": "openid profile email offline_access read:me:authentication_methods create:me:authentication_methods update:me:authentication_methods delete:me:authentication_methods read:me:factors",
      "sub": "auth0|6650…",
      "azp": "<AUTH0_CLIENT_ID>",
      "exp": 1767312000
    }
  5. Call the My Account API

    BFF → My Account API
    # 5. The SDK stores the tokens in the encrypted __session cookie.
    #    Each BFF route reads the token and calls the API on the SAME domain:
    GET https://login.example.com/me/v1/authentication-methods
    Authorization: Bearer eyJhbGciOi…
  6. Refresh when the token expires

    auth0.getAccessToken() handles this. You only add audience and scope yourself with option B, where the login token was for a different API.

    POST /oauth/token (refresh_token)
    # 6. When the access token expires, auth0.getAccessToken() refreshes it:
    POST https://login.example.com/oauth/token
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=refresh_token
    &client_id=<AUTH0_CLIENT_ID>
    &client_secret=<AUTH0_CLIENT_SECRET>
    &refresh_token=v1.MjQ…
    # Option B (MRRT) only: also send the audience and scope you want
    # &audience=https://login.example.com/me/
    # &scope=read:me:authentication_methods create:me:authentication_methods …

    If no refresh_token came back in step 3, check that offline_access is in the login scope and that the API allows offline access.

Option B: keep your own API as the login audience (MRRT)

Here the token from step 3 is for https://api.poc-app.local, so it can't call /me/v1. The first time a Security page call needs the My Account API, getAccessToken({ audience, scope }) sends step 6 with audience and scope, and Auth0 answers with a second access token for https://{domain}/me/. In developer mode this shows up as a TOKEN entry with the new token's aud and scope (never the token itself).

Tenant prerequisites:

  • Your API (https://api.poc-app.local) exists and has Allow Offline Access on, so the login returns a refresh token.
  • The application is first-party, and the APIs in the policy allow skipping user consent.
  • The user client grant for the My Account API still exists (setup step 4). MRRT doesn't replace it.
  • The application's refresh token policy lists the My Account audience and scopes:
terminal
# Option B only: allow the refresh token to mint My Account tokens.
# Read the current settings first. This PATCH replaces the whole
# refresh_token object, so keep the fields you already have.
auth0 api get "clients/<AUTHLABX_CLIENT_ID>" | jq .refresh_token

auth0 api patch "clients/<AUTHLABX_CLIENT_ID>" --data '{
  "refresh_token": {
    "rotation_type": "rotating",
    "expiration_type": "expiring",
    "policies": [
      {
        "audience": "https://login.example.com/me/",
        "scope": [
          "read:me:factors",
          "read:me:authentication_methods",
          "create:me:authentication_methods",
          "update:me:authentication_methods",
          "delete:me:authentication_methods"
        ]
      }
    ]
  }
}'

Option C: Custom Token Exchange

Here the BFF sends the login access token (audience https://api.poc-app.local) as subject_token. Auth0 picks the Token Exchange Profile by subject_token_type and runs its Action. The Action verifies the token and tells Auth0 which user it belongs to with api.authentication.setUserById(). Auth0 then issues a My Account token for that user. Nothing is stored in the session: the SDK doesn't cache CTE tokens, so AuthLabX keeps them in server memory until a minute before they expire.

Option C: login token → Custom Token Exchange → My Account token
Next.js BFF/oauth/tokenCTE Action…/me/v1grant_type=…:token-exchangesubject_token=<login token> · audience=…/me/onExecuteCustomTokenExchangematched by subject_token_typejwtVerify(issuer, audience, azp)api.authentication.setUserById(sub)200 access_token (aud …/me/)GET /me/v1/authentication-methods

1 · The Action

Action · Custom Token Exchange trigger
/**
 * Trigger: custom-token-exchange
 * Accepts this app's login access token (aud = your API) and maps it to the
 * same Auth0 user, so Auth0 can issue a My Account token for them.
 *
 * Dependency: jose        Secrets: DOMAIN, API_AUDIENCE, CLIENT_ID
 */
const { jwtVerify, createRemoteJWKSet } = require("jose");

exports.onExecuteCustomTokenExchange = async (event, api) => {
  const jwks = createRemoteJWKSet(new URL(`https://${event.secrets.DOMAIN}/.well-known/jwks.json`));
  try {
    const { payload } = await jwtVerify(event.transaction.subject_token, jwks, {
      issuer: `https://${event.secrets.DOMAIN}/`,   // your login (custom) domain
      audience: event.secrets.API_AUDIENCE,           // https://api.poc-app.local
      algorithms: ["RS256"],
    });
    // Only accept tokens issued to this app, not to any client of your API.
    if (payload.azp !== event.secrets.CLIENT_ID) {
      return api.access.rejectInvalidSubjectToken("subject_token issued to another client");
    }
    api.authentication.setUserById(payload.sub);
  } catch {
    // Feeds Attack Protection (suspicious IP throttling for CTE)
    api.access.rejectInvalidSubjectToken("Invalid subject_token");
  }
};

2 · Profile and application settings

terminal
# 1. Create the Action: Dashboard → Actions → Library → Create Action,
#    trigger "Custom Token Exchange". Paste the code above, add the
#    dependency jose and the secrets DOMAIN, API_AUDIENCE, CLIENT_ID.
#    Deploy it, then copy its id.

# 2. Bind it to a Token Exchange Profile. subject_token_type is the URI the
#    app sends (AUTH0_CTE_SUBJECT_TOKEN_TYPE); it must not use urn:auth0,
#    urn:ietf, urn:okta or the auth0.com / okta.com domains.
auth0 api post "token-exchange-profiles" --data '{
  "name": "authlabx-my-account",
  "subject_token_type": "urn:poc-app:api-access-token",
  "action_id": "<ACTION_ID>",
  "type": "custom_authentication"
}'

# 3. Allow the application to use Custom Token Exchange
#    (Dashboard: Applications → AuthLabX → Settings → Custom Token Exchange)
auth0 api patch "clients/<AUTHLABX_CLIENT_ID>" --data '{
  "token_exchange": { "allow_any_profile_of_type": ["custom_authentication"] }
}'

3 · The token request and the app code

POST /oauth/token (token-exchange)
# Server → Auth0. The subject token is the login access token (aud = your API).
POST https://login.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&client_id=<AUTH0_CLIENT_ID>
&client_secret=<AUTH0_CLIENT_SECRET>
&subject_token=eyJhbGciOi…                     # aud: https://api.poc-app.local
&subject_token_type=urn:poc-app:api-access-token
&audience=https://login.example.com/me/
&scope=openid profile email offline_access read:me:authentication_methods …

# ← 200 { "access_token": "eyJ…", "expires_in": 86400, "scope": "…", … }
src/lib/session.ts (excerpt)
// src/lib/session.ts (excerpt). The SDK does not cache CTE results,
// so AuthLabX keeps them in memory until a minute before they expire.
const subject = await auth0.getAccessToken();      // login token, aud = your API
const res = await auth0.customTokenExchange({
  subjectToken: subject.token,
  subjectTokenType: cte.subjectTokenType,           // urn:poc-app:api-access-token
  audience: myAccount.audience,                     // https://{domain}/me/
  scope: myAccount.scopes.join(" "),
});
return new LiveMyAccountClient(res.accessToken);

Passkeys on a custom domain

Auth0 binds passkeys to your custom domain through the WebAuthn rp.id. A browser only creates a credential when the page's host is that domain or a subdomain of it. So with rp.id = login.example.com, passkey and WebAuthn enrollment from http://localhost:3000 fails with SecurityError. Authenticator app, push, SMS, email and recovery codes still work from localhost.

To test passkeys locally, run the app on a subdomain of the rp.id over HTTPS:

local passkey testing
# Passkeys on a custom domain: run the app on a subdomain of the rp.id
# 1. /etc/hosts
127.0.0.1   app.login.example.com

# 2. A trusted local certificate (WebAuthn needs HTTPS outside localhost)
mkcert app.login.example.com

# 3. Run Next.js with that certificate and update the URLs
next dev --experimental-https \
  --experimental-https-key ./app.login.example.com-key.pem \
  --experimental-https-cert ./app.login.example.com.pem
# APP_BASE_URL=https://app.login.example.com:3000
# Allowed Callback URL:  https://app.login.example.com:3000/auth/callback

§ 06/me/v1

Endpoint reference

Base URL https://{domain}/me/v1. Every request carries Authorization: Bearer <token>. Errors are RFC 7807 problem documents (type, status, title, detail, optional validation_errors).

GET/me/v1/factors›

Lists the factors enabled on the tenant and available to this user. Drive your 'Add a method' UI from this instead of hard-coding.

scope: read:me:factors

response 200
{
  "factors": [
    { "type": "passkey", "usage": ["primary"] },
    { "type": "totp", "usage": ["secondary"] },
    { "type": "recovery-code", "usage": ["secondary"] }
  ]
}
GET/me/v1/authentication-methods›

All methods of the signed-in user, including password and passkeys. MFA methods include confirmed and last_auth_at.

scope: read:me:authentication_methods

response 200
{
  "authentication_methods": [
    {
      "id": "passkey|dev_9f2…",
      "type": "passkey",
      "created_at": "2026-10-29T09:12:44.000Z",
      "usage": ["primary"]
    },
    {
      "id": "totp|dev_K1x…",
      "type": "totp",
      "created_at": "2026-10-29T09:15:02.000Z",
      "usage": ["secondary"],
      "confirmed": true
    }
  ]
}
POST/me/v1/authentication-methods›

Starts enrollment. The body depends on type; the response carries the challenge to complete. For passkeys the id is only in the Location header.

scope: create:me:authentication_methods

request body
{ "type": "passkey", "connection": "Username-Password-Authentication" }
response 201
{
  "auth_session": "Fe26.2**…",
  "authn_params_public_key": {
    "challenge": "o3mm8u5…",
    "rp": { "id": "login.example.com", "name": "Example" },
    "user": { "id": "dXNlcl8x…", "name": "ada@example.com", "displayName": "Ada" },
    "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 }],
    "authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" },
    "timeout": 60000
  }
}
POST/me/v1/authentication-methods/{id}/verify›

Completes the enrollment started with the same auth_session. Body depends on type: authn_response (WebAuthn), otp_code (TOTP / phone / email) or nothing extra (recovery code / push).

scope: create:me:authentication_methods

request body
{ "auth_session": "Fe26.2**…", "otp_code": "482913" }
response 200
{
  "id": "totp|dev_K1x…",
  "type": "totp",
  "created_at": "2026-10-29T09:15:02.000Z",
  "usage": ["secondary"],
  "confirmed": true
}
GET/me/v1/authentication-methods/{id}›

Returns a single method. Use the id exactly as returned by the API.

scope: read:me:authentication_methods

PATCH/me/v1/authentication-methods/{id}›

Updates name (friendly label, e.g. for passkeys and security keys) or preferred_authentication_method (sms | voice) for phone.

scope: update:me:authentication_methods

request body
{ "name": "MacBook Touch ID" }
DELETE/me/v1/authentication-methods/{id}›

Deletes the method. Returns 204 No Content.

scope: delete:me:authentication_methods

§ 07one shape, every factor

The enrollment pattern

Every factor is enrolled in the same two calls. Learn this shape once and each flow below is a variation of step 2.

Generic enrollment: start → prove → verify
BrowserNext.js BFFMy Account APIPOST /api/my-account/authentication-methods{ type, …factor fields }POST /me/v1/authentication-methodsBearer <My Account token>201 { id, auth_session, …challenge }challenge datauser proves possession (step 2)POST …/{id}/verify{ auth_session, otp_code | authn_response }POST /me/v1/authentication-methods/{id}/verify200 authentication method
  • auth_session ties the verify call to the start call. Treat it as opaque and short-lived; start over if it expires.
  • id is the new method's id. For passkey it is not in the body; read it from the Location header (see below).
  • Until verify succeeds the method is not usable. MFA methods expose confirmed: false while pending.
typestart bodystart returnsverify body
passkeyconnection?, identity_user_id?auth_session, authn_params_public_key (+Location)auth_session, authn_response
webauthn-platform-id, auth_session, authn_params_public_keyauth_session, authn_response
webauthn-roaming-id, auth_session, authn_params_public_keyauth_session, authn_response
totp-id, auth_session, barcode_uri, manual_input_code?auth_session, otp_code
push-notification-id, auth_session, barcode_uriauth_session
phonephone_number, preferred_authentication_method?id, auth_sessionauth_session, otp_code
emailemailid, auth_sessionauth_session, otp_code
recovery-code-id, auth_session, recovery_codeauth_session

§ 08passkey · webauthn-platform · webauthn-roaming

Flow: passkeys & WebAuthn

passkey is a discoverable credential usable as a primary login (no password). webauthn-platform and webauthn-roaming are WebAuthn credentials used as a second factor. All three use the same ceremony.

Passkey enrollment
AuthenticatorBrowserNext.js BFFMy Account APIPOST …/authentication-methods{ "type": "passkey" }POST /me/v1/authentication-methods201 + Location: …/{id}{ auth_session, authn_params_public_key }{ id, auth_session, options }in the browsernavigator.credentials.create()challenge, rp, user (ArrayBuffers)PublicKeyCredential (attestation)back to the serverPOST …/{id}/verify{ auth_session, authn_response (base64url) }POST /me/v1/authentication-methods/{id}/verify200 passkey enrolled

1 · Start and capture the id from Location

src/lib/my-account/client.ts
async createAuthenticationMethod(body: CreateAuthenticationMethodRequest) {
  const { data, headers } = await this.request<CreateAuthenticationMethodResponse>(
    "POST", "/authentication-methods", body,
  );
  // Passkey responses carry no `id` in the body: the new method's id is the
  // last segment of the Location header. Use it exactly as returned.
  const id = data.id ?? idFromLocation(headers.get("location"));
  if (!id) throw new Error("Enrollment started but no authentication method id was returned");
  return { ...data, id };
}

2 · Convert options, run the ceremony, serialise the result

The API sends challenge and user.id as base64url strings. navigator.credentials.create() wants ArrayBuffers, and returns ArrayBuffers that must go back as base64url.

src/lib/webauthn.ts
// API options (JSON, base64url) → what navigator.credentials.create() accepts
export function toCreationOptions(o: PublicKeyCredentialCreationOptionsJSON) {
  return {
    ...o,
    challenge: base64urlToBuffer(o.challenge),
    user: { ...o.user, id: base64urlToBuffer(o.user.id) },
  } as PublicKeyCredentialCreationOptions;
}

// Credential from the authenticator → `authn_response` for /verify
export function toAttestationJSON(cred: PublicKeyCredential) {
  const r = cred.response as AuthenticatorAttestationResponse;
  return {
    id: cred.id,
    rawId: bufferToBase64url(cred.rawId),
    type: "public-key",
    authenticatorAttachment: cred.authenticatorAttachment ?? undefined,
    clientExtensionResults: cred.getClientExtensionResults(),
    response: {
      clientDataJSON: bufferToBase64url(r.clientDataJSON),
      attestationObject: bufferToBase64url(r.attestationObject),
      transports: r.getTransports?.(),
    },
  };
}
src/components/security/enroll-dialog.tsx (excerpt)
const start = await myAccountApi.enroll({ type: "passkey" });           // step 1
const cred = (await navigator.credentials.create({                       // step 2
  publicKey: toCreationOptions(start.data.authn_params_public_key),
})) as PublicKeyCredential;
await myAccountApi.verify(start.data.id, {                               // step 3
  auth_session: start.data.auth_session,
  authn_response: toAttestationJSON(cred),
});

§ 09totp · push-notification

Flow: authenticator app & Guardian push

The start call returns a barcode_uri (an otpauth:// URI) to render as a QR code. For TOTP it may also return manual_input_code, the secret for users who can't scan. Confirm with the current 6-digit code.

TOTP: start, render, verify
const { data } = await myAccountApi.enroll({ type: "totp" });
// data = { id, auth_session, barcode_uri: "otpauth://totp/…", manual_input_code? }

const qr = await QRCode.toDataURL(data.barcode_uri);   // render locally, never via a 3rd party
// …user scans, types the code…

await myAccountApi.verify(data.id, {
  auth_session: data.auth_session,
  otp_code: "482913",
});

For push-notification the user scans the QR code with Auth0 Guardian, approves, and you call verify with only auth_session.

§ 10phone · email

Flow: SMS, voice & email

Auth0 sends the code when the start call succeeds. Your UI only asks for it and calls verify.

POST /me/v1/authentication-methods
{
  "type": "phone",
  "phone_number": "+4915112345678",
  "preferred_authentication_method": "sms"
}
POST /me/v1/authentication-methods/{id}/verify
{ "auth_session": "…", "otp_code": "482913" }
  • phone_number must be in E.164 format (+4915112345678). Normalise it before sending.
  • preferred_authentication_method is sms or voice and can be changed later with PATCH.
  • SMS is open to SIM-swap attacks. Offer it as a fallback next to a passkey or TOTP, not as the only factor.

§ 11recovery-code

Flow: recovery code

The start call returns recovery_code. It is shown once. Make the user acknowledge they stored it, then confirm with verify (auth_session only). Offer copy and download, and never log it.

201 response
{
  "id": "recovery-code|dev_…",
  "auth_session": "…",
  "recovery_code": "Q4ZK7M2X9PBN3T8WJ6HC5RDA"
}

§ 12day-2 operations

List, rename, remove

src/lib/my-account/client.ts (excerpt)
listAuthenticationMethods()        // GET    /authentication-methods
updateAuthenticationMethod(id, b)  // PATCH  /authentication-methods/{id}   { name } | { preferred_authentication_method }
deleteAuthenticationMethod(id)     // DELETE /authentication-methods/{id}   → 204

// ids look like "passkey|dev_9f2…", so always encode the path segment:
`/authentication-methods/${encodeURIComponent(id)}`
  • Use the method id exactly as Auth0 returned it, URL-encoded as a single path segment. Ids contain |.
  • usage tells you whether a method is primary (login, e.g. passkey, password) or secondary (MFA).
  • Re-read the list after deletes instead of updating local state optimistically. The server is the source of truth.
  • Think about the last factor: if MFA is required on your tenant, warn users before they remove their only second factor.

§ 13strongly recommended

Step-up protection

Anyone holding a session can call these endpoints. If an attacker steals a session, adding their own passkey gives them permanent access. Auth0 strongly recommends step-up authentication before sensitive My Account operations such as enrolling a method.

Two layers work well together:

  1. Tenant (enforcement). A post-login Action that requires MFA when a My Account token is requested and the user already has a factor:
Action · post-login · require-mfa-for-my-account
/**
 * Require MFA whenever a token for the My Account API is issued
 * to a user who already has a second factor.
 */
exports.onExecutePostLogin = async (event, api) => {
  const audience = event.resource_server?.identifier ?? "";
  const isMyAccount = audience.endsWith("/me/");
  const hasFactor = (event.user.multifactor ?? []).length > 0;

  if (isMyAccount && hasFactor) {
    api.multifactor.enable("any", { allowRememberBrowser: false });
  }
};
  1. App (UX). When the BFF gets a 401/403, offer a re-login that forces a fresh MFA challenge. @auth0/nextjs-auth0 forwards query params from /auth/login to /authorize:
src/app/profile/security/page.tsx
import { MFA_ACR } from "@/lib/config";
// MFA_ACR = "http://schemas.openid.net/pape/policies/2007/06/multi-factor"

const stepUpHref = `/auth/login?${new URLSearchParams({
  acr_values: MFA_ACR,   // ask Auth0 for multi-factor
  max_age: "0",          // a live session must not satisfy it silently
  returnTo: "/profile/security",
})}`;

§ 14same page, Auth0's UI

Alternative: Auth0 Universal Components

Everything above builds the Security page by hand. Auth0 also ships it as React components: UserPasskeyManagement and UserMFAManagement from @auth0/universal-components-react. AuthLabX renders both at /profile/security/components, next to the custom build, so you can compare them against the same account.

Universal Components in a Next.js app (proxy mode)
Componentsnextjs-auth0 /me/*/oauth/token…/me/v1GET /me/authentication-methodsauth0-scope + scope header, session cookierefresh_token grant (if no cached /me/ token)audience=https://{domain}/me/My Account access tokenGET /me/v1/authentication-methodsAuthorization: Bearer …200, streamed back unchanged

What you write

terminal
npm install @auth0/universal-components-react react-hook-form @tanstack/react-query
src/components/security/universal-panel.tsx (simplified)
"use client";
import "@auth0/universal-components-react/styles";          // scoped to .auth0-universal
import { UserMFAManagement, UserPasskeyManagement } from "@auth0/universal-components-react";
import { Auth0ComponentProvider } from "@auth0/universal-components-react/rwa";

export function UniversalPanel({ domain }: { domain: string }) {
  return (
    <Auth0ComponentProvider
      mode="proxy"
      domain={domain}
      // "/" → the components call /me/<path> on this app, where the
      // nextjs-auth0 middleware proxies them to https://{domain}/me/v1/<path>
      proxyConfig={{ baseUrl: "/", fetcher }}
      themeSettings={{ theme: "default", mode: "dark" }}
    >
      <UserPasskeyManagement />
      <UserMFAManagement factorConfig={{ passkey: { visible: false } }} />
    </Auth0ComponentProvider>
  );
}

// The components send the scope in "auth0-scope"; the nextjs-auth0 proxy
// reads "scope". Copy it over (and record the call for developer mode).
async function fetcher(url: string, init?: RequestInit) {
  const headers = new Headers(init?.headers);
  const scope = headers.get("auth0-scope");
  if (scope && !headers.has("scope")) headers.set("scope", scope);
  return fetch(url, { ...init, headers });
}
src/lib/auth0.ts (excerpt)
// src/lib/auth0.ts: scopes per audience. With your own API as the login
// audience, the /me/ entry is what the proxy requests via the refresh token.
authorizationParameters: {
  audience: "https://api.poc-app.local",
  scope: {
    "https://api.poc-app.local": "openid profile email offline_access",
    "https://login.example.com/me/":
      "openid profile email offline_access read:me:factors read:me:authentication_methods " +
      "create:me:authentication_methods update:me:authentication_methods delete:me:authentication_methods",
  },
},
src/proxy.ts
// src/proxy.ts: nothing extra to configure. auth0.middleware() answers
// /auth/* and also proxies /me/* (My Account) and /my-org/* (My Organization).
export async function proxy(request: NextRequest) {
  if (!auth0 || isDemoMode) return NextResponse.next(); // demo: src/app/me/[...path]
  return auth0.middleware(request);
}

Custom build or components?

Custom build (sections 06–13)Universal Components
UIYours, fullyAuth0's; themes, CSS variables, class overrides, texts
Server codeBFF route handlers per endpointNone: the SDK's built-in /me/* proxy
WebAuthn, QR codes, OTP formsYou implement themIncluded
My Account tokenLogin token, MRRT or Custom Token ExchangeLogin token or MRRT (the proxy uses the refresh token)
Step-up before changesYour own flow (section 13)Same Action; the components show the API error

§ 15where everything lives

Code walkthrough

authlabx/src
src/
├─ proxy.ts                         mounts /auth/* routes (Next.js 16 "proxy")
├─ lib/
│  ├─ auth0.ts                      Auth0Client: audience + scopes + claim allow-list
│  ├─ config.ts                     domain, audience, scopes, demo-mode switch
│  ├─ session.ts                    current user + My Account client for this request
│  ├─ webauthn.ts                   base64url ⇄ ArrayBuffer for the ceremony
│  └─ my-account/
│     ├─ types.ts                   request / response types (mirror @auth0/myaccount-js)
│     ├─ client.ts                  LiveMyAccountClient: fetch → https://{domain}/me/v1
│     ├─ mock.ts                    MockMyAccountClient: demo mode
│     ├─ bff.ts                     route-handler wrapper, returns { ok, data, trace }
│     └─ browser.ts                 browser → /api/my-account/* helpers
├─ app/
│  ├─ api/my-account/…              the BFF routes (one per endpoint)
│  ├─ profile/
│  │  ├─ layout.tsx                 left menu: Data, Security
│  │  ├─ data/page.tsx              identity from the session
│  │  └─ security/page.tsx          the live POC
│  └─ pocs/my-account-mfa/          this page
└─ components/security/             SecurityPanel, EnrollDialog, TracePanel

The BFF wrapper

Every route handler is a one-liner on top of this. It builds the right client (live or mock), runs the call and returns the trace for developer mode.

src/lib/my-account/bff.ts
export async function bff<T>(fn: (api: MyAccountApi) => Promise<T>) {
  let trace: TraceEntry[] = [];
  try {
    const api = await getMyAccountClient();   // live or mock, token stays here
    trace = api.trace;
    const data = await fn(api);
    return NextResponse.json({ ok: true, data, trace });
  } catch (e) {
    const error = e instanceof MyAccountError ? e.problem : toInternalProblem(e);
    return NextResponse.json({ ok: false, error, trace }, { status: error.status || 500 });
  }
}

A route handler

src/app/api/my-account/authentication-methods/route.ts
/** GET → GET /me/v1/authentication-methods */
export async function GET() {
  return bff((api) => api.listAuthenticationMethods());
}

/** POST → POST /me/v1/authentication-methods (start enrollment) */
export async function POST(req: NextRequest) {
  const body = (await req.json()) as CreateAuthenticationMethodRequest;
  if (body.type === "passkey" && myAccount.passkeyConnection && !body.connection) {
    body.connection = myAccount.passkeyConnection;
  }
  return bff((api) => api.createAuthenticationMethod(body));
}

§ 16

Errors & troubleshooting

SymptomLikely causeFix
401 UnauthorizedToken missing or expired, or issued for a different domain than the one you call.Use one domain everywhere. Make sure offline_access is requested so the SDK can refresh.
401 invalid audience / issuerToken minted on the canonical domain but the API called on the custom domain (or the reverse).Set AUTH0_DOMAIN to the custom domain, update the client grant audience, log out and in again.
403 insufficient scopeThe scope was not requested at login, or the client grant doesn't include it.Add it to both the client grant (subject_type: user) and authorizationParameters.scope, then log in again.
403 access denied (no grant)The My Account API uses require_client_grant; the app has no grant.Create the user client grant (setup step 4).
400 on POST, type XFactor not enabled on the tenant, or invalid fields (e.g. phone not E.164).Check GET /factors and the validation_errors array.
passkey: no id in responseExpected: passkey ids come from the Location header.Read headers.get('location') and take the last path segment.
SecurityError in create()The page origin is not allowed for the rp.id Auth0 returned.Run the app on the rp.id host or a subdomain of it, over HTTPS (section 05).
NotAllowedError in create()User cancelled, timed out, or the browser blocked the prompt.Let the user retry. The auth_session is still valid until it expires.
400 on verifyWrong / expired code, or auth_session expired or reused.Restart enrollment for expired sessions; show validation_errors for bad codes.
404 on PATCH/DELETEThe id was modified (decoded, trimmed, double-encoded).Use the id as returned and encode it once with encodeURIComponent.
429 Too Many RequestsRate limit on the My Account API.Back off using the x-ratelimit-reset header; don't poll the list endpoint.

§ 17

Production checklist

  • Keep the My Account token server-side (BFF) or, for SPAs, use DPoP and short lifetimes.
  • Request only the scopes your UI uses; the client grant should match exactly.
  • Enforce step-up (Action) before enrollment and deletion for users who already have a factor.
  • Enable recovery codes so users can't lock themselves out.
  • Prefer passkeys / WebAuthn, then TOTP. Offer SMS only as a fallback.
  • Render TOTP QR codes locally; never log barcode_uri, manual_input_code or recovery_code.
  • Use the same Auth0 domain (custom domain) for login, audience and API calls.
  • Make sure your app origin is valid for the passkey rp.id in production.
  • Warn before a user removes their last second factor if MFA is mandatory.
  • Handle 429 with backoff and surface problem+json details to the user in plain language.

§ 18no tenant required

Demo mode

With AUTHLABX_DEMO_MODE=true, or when the AUTH0_* variables are missing, AuthLabX swaps LiveMyAccountClient for MockMyAccountClient. The UI, the BFF routes and the trace are identical; only the last hop is simulated.

  • Same paths, payloads, status codes, Location header and problem+json errors as the real API.
  • TOTP is real: scan the QR code with any authenticator app and its codes verify (RFC 6238). There is also a "fill current code" helper.
  • Passkeys are real: your browser runs a genuine WebAuthn ceremony against localhost. The mock checks challenge, type and origin.
  • SMS and email "send" the code 123456. Push approval is simulated.
  • Data lives in server memory per demo session. Use Reset demo data on the Security page.

Ready to try it?

Open Profile → Security, turn on developer mode and enroll a passkey. Watch the three calls above happen.

Open the live demo →