Auth0 Token Vault with Organizations: Per-Org Isolation and Three Setup Gotchas
I have written previously about Auth0's Token Vault, which lets your application call a third-party API such as Google Calendar or GitHub on a user's behalf, without your application ever seeing the user's password for that provider. Token Vault with Organization support extends this to a common B2B scenario: a user who belongs to more than one Organization gets a genuinely separate set of connected accounts per org, not one shared connection that follows them everywhere.
It is worth being precise about what this is not, because the name invites the wrong mental model. This is still ordinary Token Vault: the user defines the connection themselves and has to accept it, exactly as they would without Organizations in the picture. Isolation here is per-user, partitioned by org_id. A consultant working across two client organisations still has to connect their own Google account separately in each one; what changes is that the connection now persists independently per org, rather than needing a fresh global reconnection every time they switch context.
If what you actually want is an administrator provisioning access for the whole organisation rather than each member consenting individually, that is a different feature entirely: Cross App Access (XAA), which puts the authorization decision at the organisation's own workplace IdP rather than at the individual user. It needs the org's IdP to support the Identity Assertion Authorization Grant XAA is built on, so it is worth checking that before assuming it is a drop-in replacement for the per-user model described in this post.
In this post I will detail what the feature actually does, then walk through three setup gotchas I hit getting a demo application working end to end, each with an error message that did not obviously point at the missing piece.
The steps we will cover in this post are as follows.
- What per-org isolation actually looks like
- Grant-type errors that look like platform gating but are not
- The MRRT policy step that is easy to forget even when everything else is right
- Why Auth0's logout endpoint rejects a perfectly reasonable-looking redirect URL
What Per-Org Isolation Actually Looks Like
The mechanism reuses the same Connected Accounts flow and getAccessTokenForConnection() call as plain Token Vault. There is no organization parameter on that call, and there does not need to be. The isolation comes from the session itself: a login that carries organization=<org_id> as an authorization parameter produces a refresh token scoped to that org, and every My Account API token minted off that refresh token, including the one the Connect flow uses, inherits the same scoping. Connect Google while authenticated into Org A, and Auth0 stores that connection against Org A. Switch to Org B, and that connection is simply not there, because the exchange that would surface it is asking on behalf of a different org context entirely.
This is architectural isolation rather than a policy check somewhere in application code that could be misconfigured or bypassed. Each member of an Organization still has to complete the Connect flow themselves, once per org they belong to - what Organization support buys you is that a user who is a member of several orgs does not have to choose one global connection state, or reconnect a provider every time they change org context. Each org gets its own, and disconnecting in one leaves the others untouched.
Grant-Type Errors That Look Like Platform Gating but Are Not
The first real obstacle looked like a hard platform limitation. Adding the urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token grant type to a freshly created application, the same grant Token Vault always needs, returned a flat 400 Invalid grant types on the Management API PATCH call. My first conclusion was that a brand-new application could not be given this grant type at all, on a tenant where an older application already had it working - which reads like an inconsistency serious enough to need Auth0's product team involved.
It was not that. Auth0's own Configure Token Vault documentation is explicit that only certain client shapes can be given this grant type at all:
- The client must be a first-party client (
is_first_party: true). - The client must be confidential, with
token_endpoint_auth_methodnot set tonone. - The client must be OIDC conformant (
oidc_conformant: true).
Comparing the failing application against a working one from an earlier Token Vault build showed exactly two of these unmet: token_endpoint_auth_method was null and oidc_conformant was false, both defaults on a client created a particular way rather than anything to do with Organizations. Setting both correctly, following the same Configure Token Vault guide's application setup section, and retrying the grant-type PATCH succeeded immediately.
curl --request PATCH 'https://{yourDomain}/api/v2/clients/{clientId}' \
--header 'Authorization: Bearer {managementApiToken}' \
--header 'Content-Type: application/json' \
--data '{
"oidc_conformant": true,
"token_endpoint_auth_method": "client_secret_post"
}'(Note: apply this before the grant-type PATCH, not after - the grant type addition will keep 400ing until both prerequisites are in place.)
The lesson generalises beyond this one feature. A 400 on a grant-type change reads like a wall, but it is worth checking a new application's own configuration against the documented prerequisites before treating it as one.
The MRRT Policy Step That Is Easy to Forget
With the grant type in place, login worked and the Connect flow for Google and GitHub both completed. Calling getAccessTokenForConnection() afterwards, though, produced "An unexpected error occurred while trying to initiate the connect account flow" - a generic message that gave no hint which piece was missing.
The federated connection exchange runs off the primary session's refresh token, and that refresh token needs an explicit Multi-Resource Refresh Token policy authorising it against the My Account API audience, separately from the client grant that authorises the application itself. The client grant says the application is allowed to ask; the MRRT policy says this specific refresh token is allowed to be exchanged for that audience. Having one without the other looks, from the error message alone, like nothing is wrong with either.
curl --request PATCH 'https://{yourDomain}/api/v2/clients/{clientId}' \
--header 'Authorization: Bearer {managementApiToken}' \
--header 'Content-Type: application/json' \
--data '{
"refresh_token": {
"rotation_type": "non-rotating",
"expiration_type": "non-expiring",
"policies": [
{
"audience": "https://{yourDomain}/me/",
"scope": [
"create:me:connected_accounts",
"read:me:connected_accounts",
"delete:me:connected_accounts"
]
}
]
}
}'(Note: replace {yourDomain} and {clientId} with your tenant domain and the application's client ID. This is additive to whatever the client's existing refresh_token configuration already has - fetch it first with a GET and merge rather than overwriting.)
Once this policy was in place, both Connect flows worked cleanly.
Why Auth0's Logout Endpoint Rejects a Reasonable-Looking Redirect
The last gotcha is a genuine platform constraint rather than a missed configuration step, and it broke a design that looked entirely reasonable on paper. Switching Organizations mid-session needs a real logout-then-login round trip - an already-active session cookie can otherwise short-circuit a plain hit on the login route, ignoring the new organization parameter entirely. The natural way to carry "which org to log back into" through that round trip is a query parameter on the URL you return to after logout.
Auth0's /oidc/logout endpoint does not allow a dynamic query string on that one specific parameter. It validates post_logout_redirect_uri against the application's Allowed Logout URLs with an exact match, including the query string, and this is not a wildcard-matching quirk you can work around by registering a cleverer pattern - Auth0's own support documentation confirms dynamic query parameters on this specific URL will never match a pre-registered entry, full stop. A URL registered without the query string will not match a request carrying one; a URL registered with a specific query string will not match a request carrying a different one. The state parameter is the documented escape hatch for exactly this, but it is a parameter your own logout-initiating code has to construct by hand against the raw OIDC endpoint - most SDK-level logout helpers, including @auth0/nextjs-auth0's handleLogout, forward whatever returnTo value you give them straight through, with no facility for attaching an opaque value alongside it.
The fix that actually works is to not put the state on the URL at all. Set a short-lived, httpOnly cookie naming the target organisation before redirecting to logout, using a returnTo that is a bare, query-string-free URL matching an Allowed Logout URL entry exactly. That URL's own route reads the cookie back once Auth0 redirects there and does the actual redirect into the org-specific login.
// Sets the target org in a cookie, then kicks off logout with a
// query-string-free returnTo that matches an Allowed Logout URL exactly.
export async function GET(request) {
const org = request.nextUrl.searchParams.get('org');
const logoutUrl = new URL('/auth/login-orgs/logout', request.url);
logoutUrl.searchParams.set('returnTo', new URL('/api/switch', request.url).toString());
const response = NextResponse.redirect(logoutUrl);
response.cookies.set('switch_target_org', org, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 300,
});
return response;
}The cookie survives the round trip because it is set on your own application's origin and never has to travel to or from Auth0's domain at all - only the browser sees it, on the way out and on the way back. It is a small extra hop, but it is the only shape of this flow that Auth0's exact-match validation will actually accept.
Conclusion
Once all three of these were in place, the isolation behaviour itself worked exactly as documented, with no further surprises: connecting a provider in one org, switching context, and finding that org's connection genuinely absent rather than merely hidden. The friction here was entirely in one-time application provisioning, not in the design of the feature itself, and none of the three issues would have been obvious from Auth0's dashboard alone - each needed either checking the actual client configuration against documented prerequisites, or reading a support article most people would never think to search for until the error was already in front of them. If you are wiring this up yourself, checking oidc_conformant, the MRRT policy, and how you carry state through a logout redirect before you start debugging anything else will save you the same three round trips it took me.