Auth0 Session Delegation: Two Silent Failures and One Bug of My Own
Every SaaS company with a support team eventually needs a "log in as this customer" button, and most build it badly - a shared admin credential, a database script that swaps the user_id in a session table, a bespoke impersonation JWT nobody has ever threat-modelled. Auth0's Session Delegation is a paved path for exactly this pattern: an authorised actor, a support agent for example, establishes a real web session as a different user, without ever seeing that user's password, and every token that session produces carries an act claim recording exactly who did the delegating. It is built on Custom Token Exchange, and both are now generally available on B2C Professional, B2B Professional, and Enterprise plans.
The mechanism is what makes it worth reaching for over a homegrown version. The actor mints a Session Transfer Token naming the customer as the subject and their own ID token as the actor_token; redeeming it produces a session with a fixed two-hour lifetime and no refresh token, and the whole exchange lands in the tenant log as an auditable event tied to the agent's own sub, not a shared "admin" account. That is the actual value on offer here: a support agent gets exactly the access they need, for exactly as long as they need it, without your engineering team inventing and then having to secure an impersonation scheme of its own.
I added this to my Auth0 session and token demo suite alongside the Cross-Domain SSO and Native-to-Web SSO demos, which both lean on the same session_transfer_token mechanism for a different purpose - handing a session between apps for the same user, not delegating it to a different one. Getting a genuine delegated session out of this one took three separate rounds of debugging, and only one of the three produced an error message that pointed anywhere useful. In this post I will detail all three: two configuration gaps that Auth0 fails open on rather than rejecting outright, and a bug in my own demo code that hid a real, working delegation behind what looked like a sign-in screen.
The steps we will cover in this post are as follows.
- What Session Delegation actually does, and why it is worth using over a homegrown version
- The missing
delegationobject and itsaccess_denied - The device-binding mismatch Auth0 never reports as an error
- The bug in my own step logic that hid a genuine success
What Session Delegation Actually Does
The mechanism is a grant_type=urn:ietf:params:oauth:grant-type:token-exchange call to /oauth/token, with an audience of urn:{yourDomain}:session_transfer, a subject_token identifying the customer, and an actor_token that is the agent's own ID token:
const res = await fetch(`https://${DOMAIN}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
client_id: ACTOR_CLIENT_ID,
client_secret: ACTOR_CLIENT_SECRET,
audience: `urn:${DOMAIN}:session_transfer`,
subject_token: subjectToken,
subject_token_type: SUBJECT_TOKEN_TYPE,
actor_token: actorIdToken,
actor_token_type: 'urn:ietf:params:oauth:token-type:id_token',
}),
});A Custom Token Exchange Action bound to the actor client authorises the exchange, resolves the customer's real Auth0 user_id from the subject_token, and calls two API methods that are the whole point of the feature: api.authentication.setUserById() to name the subject, and api.authentication.setActor() to record who is delegating. Skip that second call and Auth0 will not issue a Session Transfer Token at all - setActor() is mandatory for this specific audience, unlike ordinary Custom Token Exchange where an actor is optional.
The resulting token gets redeemed against the target application's /authorize endpoint as a query parameter, the same mechanism Native-to-Web SSO uses, and the target ends up with an independent, cookie-based session as the customer, carrying an act claim, a fixed two-hour lifetime, and no refresh token, ever.

Failure One: The delegation Object That Doesn't Exist Until You Add It
The first attempt at redemption came back as an actual OAuth error, which in hindsight made it the easy one:
error=access_denied&error_description=Delegated access is not permitted for this application.
Auth0's Configure Session Delegation doc explains the gate: the target application needs a delegation object nested inside its session_transfer client setting, with allow_delegated_access: true and enforce_device_binding: "ip" - the only value the docs permit for delegation specifically, separate from whatever device-binding setting the client already has for ordinary session transfer. This configuration has no Dashboard toggle at all. It is Management API or Terraform only, which makes it exactly the kind of setting that is easy to never actually set, or to lose later.
A GET on the target client confirmed the gap - session_transfer was present with all its ordinary fields, but no delegation key anywhere inside it:
"session_transfer": {
"allow_refresh_token": false,
"allowed_authentication_methods": ["cookie", "query"],
"can_create_session_transfer_token": false,
"enforce_cascade_revocation": true,
"enforce_device_binding": "none",
"enforce_online_refresh_tokens": true
}The fix is a PATCH adding the missing object. Before you do this: the Management API replaces nested objects on a client wholesale rather than merging them, so the PATCH body has to carry every existing session_transfer field back alongside the new one, not just the addition:
{
"session_transfer": {
"allow_refresh_token": false,
"allowed_authentication_methods": ["cookie", "query"],
"can_create_session_transfer_token": false,
"enforce_cascade_revocation": true,
"enforce_device_binding": "none",
"enforce_online_refresh_tokens": true,
"delegation": {
"allow_delegated_access": true,
"enforce_device_binding": "ip"
}
}
}(Note: Replace session_transfer's existing fields with whatever your own target client already has set - sending a partial object here silently drops anything you leave out.)
Failure Two: The Device-Binding Mismatch Auth0 Never Reports
With delegation in place, the access_denied disappeared, but redemption still didn't produce a delegated session. It produced an entirely ordinary username-and-password login instead - no error, no act claim, and the target session's sub turned out to be the support agent's own user, not the customer's. Nothing in the browser suggested anything had gone wrong.
The actual cause was sitting in the tenant log the whole time, as a generic w-type warning rather than anything tagged to this flow specifically:
Single Sign-On failed: Session Transfer Token device binding validation failed due to IP/ASN mismatch.
This is the same lesson I hit once before on the Cross-Domain SSO demo: the mint call runs server-side, in a Vercel serverless function, so unless the actor client has Trust Token Endpoint IP Header switched on, Auth0 records the function's own outbound IP as the token's bound device, not the agent's real browser IP. The mint route was already forwarding the agent's real IP correctly:
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
const res = await fetch(requestShape.url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(clientIp ? { 'Auth0-Forwarded-For': clientIp } : {}),
},
// ...
});but forwarding the header does nothing if the receiving client is not configured to trust it. is_token_endpoint_ip_header_trusted was false on the actor client - a leftover from setting the client up fresh for this demo rather than starting from the Cross-Domain SSO client I'd already fixed this on once. Flipping it to true closed the gap, and the mismatch, and the silent fallback, all in one setting.
Separate from the fix: a session_transfer_token that fails device-binding validation, exactly like one presented to a target missing allow_delegated_access, does not fail the request. It falls back to an ordinary login page, as if the token parameter was never there at all. The only trace is a w-level tenant log entry with a description that has no relation to the other one, which means a filter written to catch "delegated access" warnings will not catch a device-binding one, and vice versa - something I only discovered because my own log panel had exactly that gap.
Failure Three: A Bug of My Own, Hiding a Real Success
Auth0 requires that no session already exist on the domain before a delegated session can be established - including the actor's own. My demo's "redeem correctly" flow accounts for this by logging the actor out immediately before redemption:
// redeemCorrectly(): log the actor out first, since Auth0 blocks a
// delegated session while any session already exists on the domain -
// including the actor's own, not just the target user's.
window.location.href = `/auth/session-delegation-actor/logout?returnTo=${...}`;That is entirely correct, and after the device-binding fix, it worked - the tenant log showed real sdel and sdeleacft entries, Auth0's dedicated codes for a successful delegated login and its subsequent code-for-token exchange. A genuine delegated session, act claim and all, existed on the target the whole time.
The demo just never showed it to me. The page derives which step to render from which sessions currently exist, and I had it checking the actor's session first:
const step: Step = !actorUser ? 'actor-auth' : targetUser ? 'complete' : 'pick-customer';Since the correct flow deliberately logs the actor out as its last step before redemption, actorUser is null on every successful run, by design. That sent the page straight back to 'actor-auth' - the plain sign-in card - regardless of whether the target had a live delegated session. A completely successful delegation rendered indistinguishably from step one, with no error, no warning, nothing to look at except a button inviting me to sign in again.
That invitation had a sting in it. Auth0's session cookie is domain-wide, and it currently held the live delegated session as the customer. Clicking sign-in on the actor tried to reuse that domain session via silent SSO, and since the actor client is not (and should not be) configured for allow_delegated_access, it hit that exact same gate a second time, now on the actor's own callback - a second access_denied, with no relation visible to the successful delegation that had just happened underneath it. The fix was to invert the check so a live target session always takes priority, regardless of whether the actor is still signed in:
const step: Step = targetUser ? 'complete' : !actorUser ? 'actor-auth' : 'pick-customer';Reading the Tenant Log as the Actual Source of Truth
The running theme across every demo in this series that touches session_transfer_token is that Auth0's /authorize endpoint is a poor witness to its own failures. A missing allow_delegated_access, a device-binding mismatch, an audience pointed at the wrong domain in the Cross-Domain SSO case - none of these come back as something an app can branch on. The tenant log is where the actual answer lives, and even there it is split between dedicated event codes (sdel, sdeleacft, sdelsa, and their f-prefixed failure counterparts) and a generic w-type warning that only a specific substring match will catch. I ended up widening that filter to match on delegat or device binding rather than either alone, and mapping the dedicated codes to plain English, since the Management API returns most of them with an empty description field.
Conclusion
Session Delegation is a genuinely useful pattern once it is wired up correctly - a support agent gets exactly the access they need, for exactly as long as they need it, with no refresh token and a tenant log entry naming them by sub every time they use it. Getting there meant fixing a setting with no Dashboard equivalent, a device-binding trust flag I had already learned about once on a different client, and a bug of my own that spent longer hiding a success than either Auth0 setting spent hiding a failure. If you are building this yourself, check session_transfer.delegation.allow_delegated_access and is_token_endpoint_ip_header_trusted on the actor client before you start debugging anything downstream, and if your own app derives UI state from which sessions exist, work out in advance which session your correct path is supposed to end without.