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.
Device Binding Doesn't Survive a Server-Side Mint - Unless You Trust the Right Header
There's a second gotcha in this demo, separate from the audience one above. Auth0's session_transfer_token grant supports enforce_device_binding, configurable per client as "ip", "asn", or "none" - the Native-to-Web SSO post covers it as one of the token's genuine anti-theft properties, binding the STT to the IP that minted it so a leaked token can't be redeemed from a different network.
That property doesn't survive a server-side mint by default. The mint call here runs in a Vercel serverless function, so without intervention Auth0 sees the server's outbound IP, not the end user's browser IP - and every redemption failed with a device-binding mismatch. My first fix attempt was to forward the real client IP via Auth0's auth0-forwarded-for header on the token request. That alone changed nothing - tenant logs kept showing the raw Vercel connection IP as "expected" even with the header present. So I disabled the check entirely (enforce_device_binding: "none" on Brand A) as a reliability workaround, and left the real fix as an open question.
Update (30 July 2026): the missing piece was Trust Token Endpoint IP Header, a per-client toggle under Advanced Settings → OAuth. Auth0 only honours auth0-forwarded-for on /oauth/token once that toggle is switched on for the minting client - sending the header without it is a no-op, which is exactly what I was hitting. Auth0's own documentation only describes this mechanism for the Client Credentials Grant, but with the toggle enabled on Brand A, it works identically for the refresh_token exchange this demo uses. enforce_device_binding: "ip" is back on, redemption succeeds, and the leaked-token gap above is closed.
One more wrinkle worth flagging: the dashboard now exposes enforce_device_binding as a "Device Binding Method" control, but it lives behind a gate - it only unlocks once at least one "Native to Web SSO Method" (Cookie or Query Authentication) is checked on that client. Brand A never itself consumes an inbound STT, so neither checkbox was semantically relevant to it, but I had to enable Query Authentication anyway just to unlock the radio group. That's a side-effect capability grant - Brand A can now technically accept a session_transfer_token via query string, a path nothing in this flow uses - that arrived from a UI gate rather than a deliberate decision. Harmless here, but worth checking for if you configure this by clicking through the dashboard instead of the Management API, which accepts the field directly with no such gate.
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. All three carry over to a server-side mint, but IP binding needs Trust Token Endpoint IP Header switched on for the minting client, or it silently fails open. 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.