Auth0 Anonymous Sessions: Cookies and Metadata Are Both Fixed at Creation
In an earlier post about stitching pre-authentication browsing history back into a known identity, I mentioned that Auth0 Anonymous Sessions were on the roadmap for later in 2026, and that until they shipped, PostHog's person merge was the practical way to bridge the gap. They have now shipped, in beta, and building the real thing against a live demo surfaced a design detail that isn't obvious from the docs until you go looking for it: a session's cookie and its metadata are both fixed the moment the session is created, and neither one moves again for the rest of that session's life.
A first-party client calls POST /anonymous/token, gets back a genuine signed access token for a visitor who has never logged in, and Auth0 also sets an encrypted session_token as an auth0_anon cookie on its own domain. Later, when that same browser hits /authorize, the cookie rides along automatically and a Post-Login Action can read the anonymous context straight off event.anonymous_session - no code on your end has to forward anything.
In this post I will detail what "fixed at creation" actually means in practice, why it is a sensible design rather than a limitation once you see the reasoning, and the pattern it forces once you take it seriously for anything you want to carry through to login.
The steps this post covers are as follows.
- What creating a session actually gives you
- Why a create call and a renewal call are not interchangeable
- The reasoning behind fixing both at birth
- What this means for anything you want to carry through to login
- The pattern that ships
What Creating a Session Actually Gives You
POST https://your-tenant.auth0.com/anonymous/token
Content-Type: application/json
{
"client_id": "YOUR_PUBLIC_CLIENT_ID",
"audience": "https://your-api-identifier",
"metadata": { "language": "en", "country": "AU" }
}The response carries the session_token, an access_token with a sub of the form anon@<uuid>, and - if the call came from a browser with credentials: 'include' - a Set-Cookie: auth0_anon=... on Auth0's own domain. That access token is a real bearer token from the first response onward; your API validates it against the tenant's JWKS exactly as it would validate any other token, sub and all.
One thing worth calling out here rather than dwelling on: issue this call from your own server rather than straight from the browser if you can. Early beta testing turned up a CORS gap that'll be fixed shortly, the actual response to this endpoint doesn't carry an Access-Control-Allow-Origin header, only the OPTIONS preflight does, so a browser can't read the response directly regardless of how the client is configured. A same-origin relay on your own backend sidesteps it entirely.
Create and Renew Are Not the Same Operation
Once a session exists, you renew it by presenting the session_token instead of metadata:
{
"client_id": "YOUR_PUBLIC_CLIENT_ID",
"audience": "https://your-api-identifier",
"session_token": "ANONYMOUS_SESSION_..."
}This correctly returns a fresh access_token for the same sub - I confirmed the identity is preserved by decoding the token from a create call and from a renewal of it side by side. What it does not do is set Set-Cookie again. I tested this two ways: passing the session_token explicitly in the body, and presenting the cookie itself with no session_token in the body at all (the transparent path). Neither produces a Set-Cookie header. Both return a clean 200 with a real access token, which is exactly what makes this easy to miss - there is no error to notice, just an absence.
The practical effect is blunt: a session created on your server (through the relay above, say) can never have its cookie handed to the browser by any later call. Only a genuine create call ever produces Set-Cookie, and a create call always mints a new anon@ identity. There is no way to renew your way into a cookie for a session the browser didn't create itself.
Why Birth-Time-Only Makes Sense
It is tempting to read this as a gap, but it holds together once you follow the logic through. Auth0's own documentation is explicit that anonymous sessions are stateless - there is no server-side record for a session, the entire thing is encoded in the token itself. With nothing to mutate, a session's identity and everything bound to it are decided once, at the moment it is minted, and stay that way. Renewal is deliberately narrow: give me a fresh access token for something that already exists, nothing more.
Re-sending Set-Cookie on every renewal would also be redundant in the overwhelmingly common case. The browser doing the renewing is almost always the same browser that did the create, so it already has a valid cookie sitting there - there is nothing to refresh. The gap only shows up in the specific case where creation and renewal happen from different places, which is a consequence of the architecture you choose (a relay for one, the browser directly for the other), not a flaw in how Auth0 built the feature.
This lines up with something else while building this: metadata being fixed at creation, with no update-in-place endpoint anywhere in the API, is deliberate, specifically to stop the metadata field being used as general data storage - store an identifier there, not a payload. Both the cookie and the metadata turn out to be the same kind of thing: properties decided once, at birth, and never revisited. Once you see it that way, the two findings stop looking like two separate gotchas and start looking like one consistent design.
What This Means for Anything You Want to Carry Through
Put the two together and the constraint is clear. Whatever you want a Post-Login Action to see about an anonymous visitor has to be in the metadata of whichever create call's cookie actually reaches the browser - and that call, being a create, mints an identity that has no relationship to whatever sub you were using for anything interactive beforehand. You cannot keep a single anonymous identity continuous from browsing through to login if any part of that browsing happened through a server-side relay, because the only call capable of producing a working cookie is a fresh create, every time.
For anything you are trying to track across that boundary - a cart, in my case - that rules out using the Auth0 sub as your storage key. The identity available at handoff time is not the identity you were using five minutes earlier.
The Pattern That Ships
The fix decouples cart continuity from Auth0 identity continuity entirely. The browser generates its own cartId - a plain UUID, nothing to do with Auth0 - and keeps it in localStorage, which survives the redirect to Universal Login and back the same way a PostHog distinct_id does in the earlier post. Every cart-add call carries that id, and a Firestore-backed store keys cart contents by it rather than by any Auth0 identity.
Right before redirecting to login or signup, one genuine create call fires - not a renewal - carrying only the cart id as metadata:
export async function sealCartForHandoff(cartId: string) {
await fetch(`https://${DOMAIN}/anonymous/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
client_id: CLIENT_ID,
audience: AUDIENCE,
metadata: { cart_id: cartId },
}),
});
}That is a real identifier in metadata, exactly per Auth0's own guidance, and because it is a create call it is the one request in the whole flow that actually sets the cookie. The Post-Login Action reads event.anonymous_session.metadata.cart_id and appends { cart_id, session_id, converted_at } to the user's app_metadata.cart_sessions - an identifier and timestamps, never the cart itself, appended per conversion rather than overwritten so running the flow twice produces a real history instead of clobbering it. After login, the page dereferences cart_id against Firestore to show the actual items - detail that lives in your own store, not Auth0's, and was never at risk of collision with whichever anon@ identity happened to complete the handoff.
Final Thoughts
None of this shows up until you go looking for it, because every individual call along the way returns a plain 200. The lesson generalises past carts: if you are relying on Auth0 Anonymous Sessions to carry anything from an anonymous visit into a Post-Login Action, decide upfront which single create call is the one whose cookie actually needs to reach the browser, and treat that call's metadata as the only channel you have - not the identity attached to it, and not any earlier session you were using along the way. If you would like a look at the demo code, get in touch.