Cross-Domain SSO with Auth0: The Session Transfer Token Audience Gotcha
I previously wrote about how Auth0's Native-to-Web SSO uses a session_transfer_token to hand a session from a mobile app to a browser without forcing a re-login. That post, and Auth0's own documentation, only ever show the native app and the web app resolving to the same Auth0 domain. Neither has to clarify which domain the token's audience parameter should name, because in that scenario there is only one.
A multi-brand tenant doesn't get that luxury. Plenty of Auth0 setups run two storefronts against the same tenant, each authenticating on its own domain - one on the tenant's default domain, the other on a verified custom domain. prompt=none can't bridge them, because Auth0's authorization server session cookie is scoped to the domain that set it; a silent authentication check from Brand B will never see a session Brand A created. In this post I will detail how I adapted the session transfer token mechanism to bridge two domains on the same tenant, and the one parameter value that determines whether the exchange actually works or just looks like it does.
The Problem: Same Tenant, Different Domains, No Shared Cookie
The demo I built has two storefronts, Brand A and Brand B, sitting on the same Auth0 tenant but different domains - Brand A authenticates against the tenant's default *.auth0.com-style domain, Brand B against a verified custom domain. Both are legitimate origins for the same tenant, but Auth0 treats the authorization server session cookie as host-scoped. Signing in on Brand A sets a cookie for Brand A's domain only; a prompt=none silent authentication attempt from Brand B has nothing to check against and always comes back login_required.
This isn't a bug, and it isn't specific to Auth0 - it's how browser session cookies work generally. The interesting question is whether the session transfer token mechanism, designed for the native-to-web case, generalises to bridging two web domains that will never share a cookie by definition.
Bridging the Domains with a Session Transfer Token
It does, with the same four-step shape as the native-to-web flow: exchange a refresh token for a short-lived STT on Brand A, then redeem it against Brand B's /authorize endpoint.

const res = await fetch(`https://${SOURCE_DOMAIN}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: BRAND_A_CLIENT_ID,
client_secret: BRAND_A_CLIENT_SECRET,
refresh_token: refreshToken,
audience: `urn:${SOURCE_DOMAIN}:session_transfer`,
}),
});Brand A's frontend then opens Brand B in a new tab with the STT attached as a query parameter, and Brand B's @auth0/nextjs-auth0 login route forwards it straight through to /authorize:
GET /auth/brand-b/login?session_transfer_token=<stt>&returnTo=/demo/cross-domain-sso
Run it correctly and Brand B ends up with its own independent, cookie-based session - established without a Universal Login prompt, without the user re-entering credentials, and without Brand A and Brand B ever sharing a cookie.
The Gotcha: The Audience Must Name the Source Domain
The parameter that decides whether any of this works is audience: urn:{domain}:session_transfer. Every intuition points at the target - you're transferring a session to Brand B, so the audience should presumably reference Brand B's domain. That intuition is wrong.
The audience has to name the domain that issued the refresh token - the source, Brand A - not the domain you're bridging to. Pass Brand B's domain instead and Auth0 doesn't reject the request. It returns 200 OK with an access_token field, expires_in: 86400, and no issued_token_type - a completely ordinary access token, silently substituted for the session transfer token you asked for. There's no error to catch, no bad-request response to branch on. The failure only shows up several steps later, when /authorize rejects the "STT" that is actually just a bearer token with the wrong shape.
The reason the documentation never has to clarify this is that Native-to-Web SSO always keeps the native app and the web app on the same domain, so source and target are the same value and the ambiguity never comes up. Cross-domain bridging is the case where it matters, and it isn't covered.
Confirming It: A Raw Token Request Comparison
Before landing on the audience domain as the cause I ruled out two other plausible culprits, both of which looked more likely at the time:
session_transfer.allow_refresh_tokenon the target client. This flag controls whether the redeemed web session gets its own refresh token - it has nothing to do with whether the exchange itself succeeds. The working reference client in the Native-to-Web demo has this set tofalse, which was a useful reality check once I noticed it.oidc_conformant. This legacy tenant-level flag turned out to matter, but not in the way I expected. With it off, an audience mismatch comes back as an explicitinvalid_granterror - noisy, but at least visible. Turning it on was necessary to get the session transfer grant recognised at all, but it's also what turns a wrong-domain audience into a silent, misleading success rather than an error. Necessary, not sufficient.
The way to confirm the actual cause is to bypass the app entirely and hit /oauth/token directly with the same refresh token, changing only the audience value between calls, and diff the two responses:
audience = source domain | audience = target domain | |
|---|---|---|
| HTTP status | 200 | 200 |
expires_in | 60 | 86400 |
issued_token_type | present | absent |
| Token type | session_transfer_token | ordinary access_token |
Both calls return 200. The only tell is in fields you have to know to check.
Building the Demo
The demo is at token-session-explainer-demo.vercel.app/demo/cross-domain-sso, alongside the broader session and token demo suite. Brand A and Brand B run as the same Next.js app behind a shared proxy layer, which routes each brand's /auth/brand-a/* and /auth/brand-b/* paths to separate @auth0/nextjs-auth0 client instances with distinct client IDs and cookie names, mirroring the separate-client-instance pattern I used for native-to-web.
The demo deliberately shows the failure mode as well as the success path. A Check SSO (prompt=none) button on Brand B fires the silent authentication attempt so you can watch it come back login_required, before the STT bridge on Brand A resolves it. The exchange panel shows the actual request payload sent to Auth0 - including the audience value - plus a 60-second countdown once the STT is issued, since it's single-use and expires fast.
Conclusion
The session_transfer_token grant generalises cleanly from native-to-web handoffs to cross-domain bridging within the same tenant, but only once you know the audience parameter names the source domain rather than the target - the opposite of what the parameter name suggests to a first-time reader. The Native-to-Web SSO post covers the token's other security properties - single-use, IP binding, cascade revocation - which all carry over unchanged to the cross-domain case. If you're running multiple branded domains on one Auth0 tenant, this is worth having in your back pocket before you spend an afternoon staring at a 200 response wondering why /authorize won't accept it.