Toby Allen

Four layers deep and a 404: finding the real Auth0 My Account API

· Auth0, My Account API, MFA, AI-Assisted Development

Auth0's My Account API is a genuinely different thing from the Management API: a purpose-built, self-service surface at /me/v1/ where a signed-in user manages their own authentication methods and profile with narrowly-scoped tokens, rather than an admin-shaped API that happens to accept a user token if you point it at the right :id. I have a demo repo full of Auth0 session and token patterns, and it already had a route that called itself "My Account API" in its own comments. It wasn't. It called /api/v2/users/:id with a user-scoped Management API token, which works, but isn't the API it claimed to be.

I asked Claude Code to build the real thing: a demo covering authentication method management against the genuine /me/v1/ endpoints. In this post I will go through what that build actually turned up - three separate pieces of tenant configuration that each produce a different, misleading error if you miss them, a step-up policy that structurally cannot be satisfied by the approach I'd planned, and a profile endpoint that, on this tenant at least, simply isn't wired up for self-service yet.

The SDK helper that quietly does nothing

The plan was straightforward: a dedicated Auth0 client, no My Account scopes requested at login, and getAccessToken({ audience, scope }) from @auth0/nextjs-auth0 called per-operation to mint a narrowly-scoped token exactly when each action needed one. That's the advertised shape of the method, and it's precisely how this demo's existing Token Vault integration already uses the same My Account audience for Connected Accounts.

It returned a token. The token just wasn't the one asked for. Every call came back with the same expiresAt and the same scope the session had at login, and its header read "alg":"dir" - an encrypted session artefact (a JWE, five dot-separated segments) rather than the signed access token the API needs to validate. Auth0 rejected it with "Bad HTTP authentication header format," which reads like a header-construction bug and isn't one. getAccessToken() was handing back the cached session token unchanged, because the cached token for the login-time audience hadn't expired yet, and asking for a different audience didn't force a real exchange on its own - refresh: true didn't change this either, on the @auth0/nextjs-auth0 v4 release this demo runs.

I asked Claude to find a different Auth0 project in the same working directory that already used this API successfully, on the theory that someone had solved this before. It had: a sibling project doing the same Connected Accounts call did a raw grant_type: refresh_token request straight against /oauth/token, bypassing the SDK helper entirely. Same fix here. Minting a correctly-scoped token for a second audience, after login, apparently isn't something this helper does for you - you do it yourself, with the refresh token the SDK is already holding.

One grant, two completely different meanings

With a real token-minting call in place, the next error was invalid_request: "Client is not authorized to access resource server." A client grant for this exact audience already existed - I'd had Claude create one at the start of the build - so this looked like it should already be solved.

The grant existed with subject_type: "client". The resource server's authorisation policy required a grant with subject_type: "user" for user-flow access, which is a completely different object despite living in the same list in the dashboard. One subject type is for machine-to-machine client credentials calls; the other is for a user token acting through the client. Creating "a client grant for this audience" without specifying which kind produces something that looks identical in a cursory read of the dashboard and satisfies neither requirement for the other flow.

Four separate layers of Auth0 tenant configuration had to align for a My Account API call to succeed: a user-subject-type client grant, a Multi-Resource Refresh Token policy, dashboard-level user-delegated API access, and the step-up assurance policy that none of the above can satisfy for a background exchange.

Fixing the subject type surfaced a third requirement: Multi-Resource Refresh Tokens. Without an explicit refresh_token.policies entry on the application authorising this audience and scope set, Auth0's documentation says the refresh token exchange "will silently ignore" an audience it doesn't recognise - no error, just the wrong token coming back, which is exactly the symptom from the first problem above and hard to distinguish from it by error message alone. And once that policy existed, there was a fourth, separate layer: explicit per-scope "User-delegated Access" grants in the dashboard's API Access screen for the application. I haven't found public Auth0 documentation describing that screen's relationship to the other three layers - it only turned up by trial, missing which produces 403 Insufficient Scope instead of anything that mentions a grant or a policy at all.

Four pieces of configuration and four different, unhelpful error signatures turned out to be one requirement in practice: let this app's users use this API.

A background token exchange cannot step up MFA

Getting past all four still didn't produce a working factor list for the first test account I tried. The error, unmet_authentication_requirements, came with its own explanation this time: "User does not meet the required authentication assurance level and cannot be challenged." The tenant has an Early Access "Default Policy," visible on the My Account API resource server's own settings screen, that requires MFA step-up for an already-enrolled user more than fifteen minutes after their original login. It's EA, so I'm not linking a public doc for it - the dashboard copy itself was the only description I found, and it may read differently by the time this ships generally.

A raw refresh_token grant has no interactive channel. There's no browser, no redirect, nowhere to show a one-time code prompt. The policy correctly recognised that this exchange couldn't satisfy a step-up challenge even in principle, and refused outright rather than quietly granting a weaker guarantee. For a demo account with no second factor enrolled, none of this applies, which is why it worked cleanly for a fresh test user and failed for one I'd deliberately enrolled a factor on along the way.

The fix for a demo is to pick a test account without that friction. The fix for anything serving real users is architectural: a background proxy driven by a refresh token exchange is the wrong shape for an action that might need step-up, and the interactive /authorize redirect that can actually prompt for a second factor is not optional in that case - it's the only one of the two that's capable of satisfying the policy at all.

The profile endpoint that doesn't exist yet

The original plan also covered reading and updating the user's own profile through /me/v1/profile. The write path returned a plain 404. The read path, once every scope and grant above was correctly in place, returned 403 Insufficient Scope - not because a scope was missing from the token, but because there's no real scope for it to hold. Auth0's own myaccount-js client library ships zero profile-resource code at all, and a direct audit of the resource server's full scope list, run against the live tenant, came back with four authentication-method scopes and nothing else.

Read access failing felt like it should have been the easier of the two to get working, by analogy with everything else on this API behaving as documented. It wasn't a configuration gap on my end; it's a capability that genuinely isn't there yet on this surface. I pulled the profile section out of the demo entirely rather than ship a permanently-broken card, and the finding went into the demo's own security notes instead - a real, confirmed limitation of the current API is a more useful thing to show a reader than a UI element that only ever displays an error.

Shipping the official component instead of hand-rolling it

Everything above was built against a hand-rolled enroll/verify/delete flow I'd had Claude write from the documented challenge-response shape for TOTP. Partway through, I asked Claude to check whether Auth0 had already shipped a pre-built UI for this, rather than keep maintaining my own. It had: @auth0/universal-components-react ships a UserMFAManagement component that handles the whole enrol-verify-delete lifecycle, including the WebAuthn and recovery-code paths my hand-rolled version never covered.

Swapping to it needed one more piece of reverse-engineering: the installed component builds its backend-proxy requests as {proxyUrl}/me/<path> with no version segment, while the real API requires /me/v1/<path> - there's no unversioned alias on this tenant. I confirmed that by reading the installed package source directly rather than guessing from the request shape alone. The fix is a one-line rewrite in the proxy route that reinserts the version segment before forwarding upstream. With that in place, enrolment and deletion through the official component worked end to end on the first real test.

Final Thoughts

None of this showed up anywhere in Auth0's own My Account API getting-started material as a thing to check: not the SDK helper's silent audience mismatch, not the subject-type distinction between two grant kinds with the same dashboard label, not the MRRT policy or the dashboard API Access screen sitting on top of it as two further separate layers, and not the step-up policy a background exchange can't satisfy. Each one surfaced only once a real token-minting call hit the live tenant and came back with an error that didn't name what was actually wrong. The profile gap showed up the same way - not from documentation saying "not yet supported," but from a 404 and a scope audit agreeing with each other.

If you're building against this API, go in expecting a user-subject-type grant, an MRRT policy, and explicit API access permissions to be three separate things you need to configure, not one grant covering all of it. And if step-up assurance matters for any of your flows, plan for an interactive redirect from the start - a server-side proxy can request a token, but it cannot stand in for the user when Auth0 decides a prompt is actually required.

Read more about the Multi-Resource Refresh Token model and the official Auth0 components if you're weighing the same build.