Auth0 Anonymous Sessions: The Cookieless Transfer Ticket Handoff
In an earlier post about Auth0 Anonymous Sessions, I covered the cookie-based handoff in detail: an auth0_anon cookie set on Auth0's own domain, fixed at the moment an anonymous session is created, riding along automatically to /authorize when the visitor logs in or signs up. That mechanism was, at the time, the only documented way to carry an anonymous session into login. Auth0's docs have since added a second one, and it solves a different problem entirely.
The cookie method only works when your application and Auth0's custom domain share a parent domain, for example app.mydomain.com alongside an authorization server at auth.mydomain.com. The cookie is same-domain in that setup and the browser sends it automatically. In a genuinely cross-domain arrangement, though, Auth0's own docs call out that browser mechanisms such as Apple's Intelligent Tracking Prevention (ITP) can block or drop that cookie even when CORS is configured correctly on both sides, which silently produces unmatched anonymous-to-known conversions rather than an obvious error. The fix is a transfer ticket: a short-lived token exchanged specifically for this handoff, carried as a query parameter instead of a cookie.
In this post I will detail how the transfer-ticket mechanism works, how it differs from the cookie handoff, and the pattern I used to add it as a second option alongside the existing cookie flow in my Anonymous Sessions demo.
What Changed in the Docs
The Anonymous Sessions documentation originally described only the cookie handoff, and it did so with an example that turned out not to match the actual API surface. Auth0 has since reconciled the docs against the real OpenAPI spec and the feature's own architecture guide, replacing that example with the transfer-ticket mechanism, and at the same time promoted Anonymous Sessions from Beta to Early Access, gated to Enterprise plans, as part of a broader "Identity Conversion Suite" pairing it with Experiment Center. The cookie mechanism is unchanged; the transfer ticket is additive.
The Transfer Ticket Mechanism
Where the cookie handoff needs no extra code at all (await auth0.loginWithRedirect() and the cookie just rides along), the transfer ticket is a deliberate two-step exchange:
- Call
POST /anonymous/tokenfor the anonymous session you already created, passing itssession_tokenand requesting the audienceurn:auth0:anon_transferinstead of your normal resource-server audience. This is a fixed, well-known URN rather than an API identifier you configure yourself. - Take the
anon_transfer_tokenfrom that response and include it as a query parameter on the/authorizecall:GET /authorize?client_id=...&anon_transfer_token=<att>.

Two things the docs get wrong or leave out, both of which I only found by actually running the exchange against a live tenant rather than trusting the written description. First, the audience: the docs originally published it as urn:auth0:anon_transfer_token, not urn:auth0:anon_transfer, and that extra _token suffix fails with invalid_target on every tenant. Auth0 engineering confirmed the correct value and fixed the docs within a day of the mismatch being reported. Second, and still not in the docs as of writing: the call requires session_token in the body. Omit it and the tenant rejects the request outright with invalid_request, "A session_token is required to request a transfer token". That makes sense once you think about what the call is actually doing. It isn't minting a brand-new anonymous identity the way the storefront-audience create call does; it's exchanging one specific, already-created session for a transfer token, so it needs to know which session.
Auth0's docs are explicit that the token is bound to the IP address that requested it, and the token itself is short-lived, thirty seconds in Auth0's own sequence diagram, so it needs to be fetched immediately before the redirect rather than held onto. There is also a tenant setting, sessions.anonymous.activate_cookie, alongside the existing lifetime_in_minutes setting. Auth0 recommends setting it to false if you're using the transfer ticket exclusively, to avoid clashes between the two mechanisms.
Everything downstream of the handoff stays the same regardless of which mechanism got the anonymous session there: the data lands in event.anonymous_session inside the post-login and pre-user-registration Action triggers exactly the same way either time.
There's a third scenario worth a brief mention for completeness: a Resource Owner Password Grant login includes the anonymous_session_token directly in the /oauth/token request body rather than going through /authorize at all. It's a narrower case and I haven't built against it, but it's documented alongside the other two.
The Pattern That Ships
Adding this as a second option to the existing demo turned out to need less new code than the cookie mechanism did. The @auth0/nextjs-auth0 SDK's login route handler forwards every query parameter it doesn't explicitly recognise (it only special-cases returnTo and challengeMode) straight through as an authorization parameter on the /authorize redirect. That's true of the version installed in this project, and I confirmed it by reading the handler directly rather than assuming it from the type definitions. Practically, that means appending anon_transfer_token to the login URL is enough; no SDK-level change is required to get it onto /authorize.
export async function getTransferTicket(sessionToken: string) {
const res = await fetch('/api/anon-sessions/transfer-ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken }),
});
return res.json();
}const result = await getTransferTicket(anonSessionToken);
params.set('anon_transfer_token', result.anon_transfer_token);
window.location.href = `/auth/anon-sessions/login?${params.toString()}`;The server-side relay behind getTransferTicket() is the same shape as the one I already had in place for the cookie flow, a same-origin call to POST /anonymous/token, just requesting the transfer-ticket audience instead of the storefront one. That relay existed in the first place because this tenant's build of Anonymous Sessions doesn't send CORS headers on the real response to /anonymous/token, only on the preflight, so a browser can't read the response directly. The transfer ticket path benefits from a relay for the same reason, but for a simpler reason than the cookie path did: there's no Set-Cookie side effect to preserve here at all, so unlike the cookie handoff, which needs a genuine browser-direct call specifically so the cookie lands on the browser, the transfer-ticket exchange can go through the relay entirely. Nothing about it needs to happen client-side except reading the token back and appending it to the redirect.
Final Thoughts
The two mechanisms exist for different failure modes rather than as a straight upgrade path. If your application and your Auth0 custom domain share a parent domain, the cookie handoff still works with zero extra code, and that's worth keeping. The transfer ticket earns its complexity specifically when cookies can't be trusted to survive the trip, cross-domain setups, aggressive browser tracking protections, or anywhere else a Set-Cookie header is a gamble rather than a guarantee. I ran the full exchange end to end against a live tenant and, after deploying the demo to production, confirmed the same thing there: POST /anonymous/token for the ticket, then GET /authorize with it attached, a clean redirect into Universal Login with no error either time. If you're building the cross-domain case, budget for the token's IP binding and thirty-second lifetime, fetch it right before the redirect, pass the originating session's session_token, and read the Anonymous Sessions documentation for the rest of the detail on activate_cookie and the ROPG scenario, keeping in mind the docs are still catching up on a couple of points this post covers.