§ 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
| Option | Who acts | Use it when |
|---|---|---|
| My Account API | The user, with their own token | You want the UI inside your product, with your design, and per-user least privilege. |
| Universal Portals | The user, on a hosted page | You prefer an Auth0-hosted self-service portal over building UI. |
| Management API | Your backend, as admin | Support 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.
Each AuthLabX route handler maps one-to-one onto a My Account endpoint:
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.
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 andAUTH0_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.comActivate 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_grantfor user flows, so every application has to be granted access explicitly (next steps). Machine-to-machine access is denied.Create the Regular Web Application
terminalauth0 apps create \ --name "AuthLabX" \ --type regular \ --callbacks "http://localhost:3000/auth/callback" \ --logout-urls "http://localhost:3000" \ --reveal-secretsCopy 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.Grant the app the My Account scopes
System APIs do not support default grants, so create a user client grant for this app:
terminalauth0 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.
Enable the factors you want to offer
GET /me/v1/factorsonly 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.
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 optionallyidentity_user_id) in the enrollment request. SetAUTH0_PASSKEY_CONNECTIONand the BFF adds it for you.Configure the app and run it
.env.localAUTHLABX_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 # optionalterminalnpm install npm run dev # http://localhost:3000
§ 04least privilege
Scopes & tokens
| Scope | Unlocks |
|---|---|
| read:me:factors | GET /factors |
| read:me:authentication_methods | GET /authentication-methods, GET /authentication-methods/{id} |
| create:me:authentication_methods | POST /authentication-methods, POST /{id}/verify |
| update:me:authentication_methods | PATCH /authentication-methods/{id} |
| delete:me:authentication_methods | DELETE /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.
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.
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",
},
});// 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.
The app redirects to /authorize on the custom domain
nextjs-auth0 builds this request from
AUTH0_DOMAINandauthorizationParameters. 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=S256Auth0 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>The server exchanges the code for tokens
This happens inside
/auth/callbackon 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" }Check the token claims
Auth0 sets
issto the domain you used for the token request.audmust 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 }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…Refresh when the token expires
auth0.getAccessToken()handles this. You only addaudienceandscopeyourself 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_tokencame back in step 3, check thatoffline_accessis 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:
# 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.
1 · The Action
/**
* 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
# 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
# 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). 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:
# 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/factorsfactors the user may enroll›
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
{
"factors": [
{ "type": "passkey", "usage": ["primary"] },
{ "type": "totp", "usage": ["secondary"] },
{ "type": "recovery-code", "usage": ["secondary"] }
]
}GET/me/v1/authentication-methodslist enrolled 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
{
"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-methodsstart enrollment›
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
{ "type": "passkey", "connection": "Username-Password-Authentication" }{
"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}/verifycomplete enrollment›
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
{ "auth_session": "Fe26.2**…", "otp_code": "482913" }{
"id": "totp|dev_K1x…",
"type": "totp",
"created_at": "2026-10-29T09:15:02.000Z",
"usage": ["secondary"],
"confirmed": true
}GET/me/v1/authentication-methods/{id}read one method›
Returns a single method. Use the id exactly as returned by the API.
scope: read:me:authentication_methods
PATCH/me/v1/authentication-methods/{id}rename / change phone channel›
Updates name (friendly label, e.g. for passkeys and security keys) or preferred_authentication_method (sms | voice) for phone.
scope: update:me:authentication_methods
{ "name": "MacBook Touch ID" }DELETE/me/v1/authentication-methods/{id}remove a method›
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.
auth_sessionties the verify call to the start call. Treat it as opaque and short-lived; start over if it expires.idis the new method's id. Forpasskeyit is not in the body; read it from theLocationheader (see below).- Until verify succeeds the method is not usable. MFA methods expose
confirmed: falsewhile pending.
| type | start body | start returns | verify body |
|---|---|---|---|
| passkey | connection?, identity_user_id? | auth_session, authn_params_public_key (+Location) | auth_session, authn_response |
| webauthn-platform | - | id, auth_session, authn_params_public_key | auth_session, authn_response |
| webauthn-roaming | - | id, auth_session, authn_params_public_key | auth_session, authn_response |
| totp | - | id, auth_session, barcode_uri, manual_input_code? | auth_session, otp_code |
| push-notification | - | id, auth_session, barcode_uri | auth_session |
| phone | phone_number, preferred_authentication_method? | id, auth_session | auth_session, otp_code |
| id, auth_session | auth_session, otp_code | ||
| recovery-code | - | id, auth_session, recovery_code | auth_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.
1 · Start and capture the id from Location
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.
// 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?.(),
},
};
}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.
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.
{
"type": "phone",
"phone_number": "+4915112345678",
"preferred_authentication_method": "sms"
}{ "auth_session": "…", "otp_code": "482913" }phone_numbermust be in E.164 format (+4915112345678). Normalise it before sending.preferred_authentication_methodissmsorvoiceand 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.
{
"id": "recovery-code|dev_…",
"auth_session": "…",
"recovery_code": "Q4ZK7M2X9PBN3T8WJ6HC5RDA"
}§ 12day-2 operations
List, rename, remove
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
idexactly as Auth0 returned it, URL-encoded as a single path segment. Ids contain|. usagetells you whether a method isprimary(login, e.g. passkey, password) orsecondary(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:
- Tenant (enforcement). A post-login Action that requires MFA when a My Account token is requested and the user already has a factor:
/**
* 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 });
}
};- App (UX). When the BFF gets a
401/403, offer a re-login that forces a fresh MFA challenge.@auth0/nextjs-auth0forwards query params from/auth/loginto/authorize:
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.
What you write
npm install @auth0/universal-components-react react-hook-form @tanstack/react-query"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: 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: 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 | |
|---|---|---|
| UI | Yours, fully | Auth0's; themes, CSS variables, class overrides, texts |
| Server code | BFF route handlers per endpoint | None: the SDK's built-in /me/* proxy |
| WebAuthn, QR codes, OTP forms | You implement them | Included |
| My Account token | Login token, MRRT or Custom Token Exchange | Login token or MRRT (the proxy uses the refresh token) |
| Step-up before changes | Your own flow (section 13) | Same Action; the components show the API error |
§ 15where everything lives
Code walkthrough
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, TracePanelThe 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.
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
/** 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
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 Unauthorized | Token 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 / issuer | Token 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 scope | The 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 X | Factor 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 response | Expected: 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 verify | Wrong / expired code, or auth_session expired or reused. | Restart enrollment for expired sessions; show validation_errors for bad codes. |
| 404 on PATCH/DELETE | The id was modified (decoded, trimmed, double-encoded). | Use the id as returned and encode it once with encodeURIComponent. |
| 429 Too Many Requests | Rate 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,
Locationheader 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 →