Delegation, Impersonation and Token Exchange

How a service acts on a user's behalf downstream without handing over more power than the operation needs.

Service A receives a request from a user and must call service B to complete it. What credential should A present to B? There are four answers and only two of them are good.

The four options

  1. Forward the user's token unchanged. Simple, and it means B receives a credential with A's full scope. Compromise B and you can call anything the user could. Also, B's audience check should reject it — if it does not, that is its own bug.
  2. Call B as a service, losing the user. Now B has no idea who the request is for, so it cannot apply per-user authorization. Every ownership check downstream is gone.
  3. Pass the user id in a header. Unauthenticated and forgeable. Fine only if B is unreachable except through A, which is rarely true for long.
  4. Token exchange (RFC 8693). A presents the user's token and receives a new one: narrower scope, audience B, and a record of who is acting for whom.

Impersonation vs delegation

The distinction interviewers look for:

  • Impersonation — the new token looks exactly like a user token. B sees Alice and nothing else. Simple, and the audit trail loses the fact that service A did it.
  • Delegation — the token names both parties. sub is Alice; the act (actor) claim says service A is acting for her. B can apply policy to the combination, and the audit log records the truth.

Prefer delegation. "Who deleted this?" should not answer "Alice" when the real answer is "the batch service, on Alice's behalf, at 3am".

Downscoping is the point

The exchanged token should carry the minimum scope for the downstream call. A request that needs invoices:read should not travel with payments:write attached, even though the user holds both.

Chains

A calls B calls C. Each exchange nests the previous actor inside act.act, producing a verifiable chain. Cap the depth — an unbounded chain is a sign something is looping.

Example

Example · bash
# Service A exchanges the user's token for a narrower one aimed at service B
curl -X POST https://auth.dfg.com/oauth/token \
  -u "$A_CLIENT_ID:$A_CLIENT_SECRET" \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token="$USERS_TOKEN" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d audience=https://billing.dfg.com \
  -d scope='invoices:read'

# The result names both parties — this is DELEGATION:
# {
#   "sub": "42",                       ← still Alice
#   "act": { "sub": "orders-service" },← acting on her behalf
#   "aud": "https://billing.dfg.com",  ← usable ONLY at billing
#   "scope": "invoices:read"           ← and only for this
# }

# Omitting the act claim would be IMPERSONATION: billing sees Alice, and the
# audit log never learns that orders-service made the call.

When to use it

  • An orders service calls billing with a token scoped to invoices:read only, so a compromise of billing cannot be escalated into payment writes.
  • An audit investigation shows a deletion was performed by the import service acting for a specific user, because the act claim was preserved.
  • A downstream service rejects a forwarded user token because its audience names the upstream API, catching a lazy propagation shortcut in review.

More examples

Performing the exchange, with caching

Caching by user AND audience AND scope keeps tokens narrow. A cache keyed on user alone would hand a payments-scoped token to a read-only call.

Example · javascript
const exchangeCache = new Map();      // (subjectSub|audience|scope) -> {token, exp}

export async function exchangeForDownstream(userToken, { audience, scope }) {
  const claims = decodeJwt(userToken);              // already verified upstream
  const cacheKey = `${claims.sub}|${audience}|${scope}`;

  const hit = exchangeCache.get(cacheKey);
  if (hit && Date.now() < hit.exp - 30_000) return hit.token;

  const res = await fetch('https://auth.dfg.com/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Authorization: 'Basic ' + Buffer.from(
        `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`).toString('base64'),
    },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: userToken,
      subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
      audience,                       // ← the token becomes unusable elsewhere
      scope,                          // ← and only for this operation
      requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
    }),
  });
  if (!res.ok) throw new Error(`token_exchange_failed_${res.status}`);

  const { access_token, expires_in } = await res.json();
  exchangeCache.set(cacheKey, {
    token: access_token,
    exp: Date.now() + expires_in * 1000,
  });
  return access_token;
}

// Usage: one exchange per (user, downstream, operation) — never one broad token
// reused for every downstream call.
app.get('/api/orders/:id/invoice', bearerAuth, async (req, res) => {
  const downstream = await exchangeForDownstream(req.rawToken, {
    audience: 'https://billing.dfg.com',
    scope: 'invoices:read',
  });

  const invoice = await fetch(`https://billing.dfg.com/invoices/${req.params.id}`, {
    headers: { Authorization: `Bearer ${downstream}` },
  }).then((r) => r.json());

  res.json(invoice);
});

Consuming a delegated token downstream

Refusing delegation on sensitive paths is a real control: it means a compromised internal service cannot do the one thing that requires a human at a keyboard.

Example · javascript
// Billing service: understand BOTH identities and apply policy to the pair.
export async function delegatedAuth(req, res, next) {
  const claims = await verifyAccessToken(readBearer(req), {
    audience: 'https://billing.dfg.com',     // ← rejects a forwarded upstream token
    issuer: 'https://auth.dfg.com',
  });

  const user = claims.sub;
  const actor = claims.act?.sub ?? null;     // null = the user called us directly

  // Some actions may be forbidden to machines acting for a user, even when the
  // user could perform them personally.
  if (actor && SENSITIVE_PATHS.some((p) => req.path.startsWith(p))) {
    return res.status(403).json({
      error: 'delegation_not_permitted',
      detail: 'This action requires a direct user session.',
    });
  }

  // And some services are simply not allowed to act for users at all.
  if (actor && !DELEGATION_ALLOWLIST.has(actor)) {
    return res.status(403).json({ error: 'actor_not_permitted', actor });
  }

  req.user = { id: user };
  req.actor = actor;
  next();
}

// The audit trail records what actually happened.
await audit.record({
  action: 'invoice.viewed',
  subject: req.user.id,        // Alice
  actor: req.actor,            // 'orders-service' — or null for a direct call
  chain: flattenActorChain(claims),   // ['orders-service', 'gateway'] for A→B→C
});

Nested chains, and capping them

The depth cap doubles as a design smell detector — services that need four hops of delegation usually have a boundary drawn in the wrong place.

Example · javascript
// A → B → C produces nesting, innermost = most recent actor:
// {
//   "sub": "42",
//   "act": {
//     "sub": "billing-service",        ← called us
//     "act": { "sub": "orders-service" }  ← called billing
//   }
// }

export function flattenActorChain(claims) {
  const chain = [];
  let node = claims.act;
  while (node?.sub) {
    chain.push(node.sub);
    node = node.act;
    if (chain.length > 8) break;          // defensive: see below
  }
  return chain;                            // ['billing-service', 'orders-service']
}

// Cap the depth at the authorization server, not just when reading it.
// An unbounded chain means either a routing loop or an attacker walking the
// graph to find a service with wider permissions.
export function assertChainDepth(claims, max = 3) {
  const depth = flattenActorChain(claims).length;
  if (depth > max) {
    throw new AuthError('invalid_token',
      `delegation chain too deep (${depth} > ${max})`);
  }
}

// Practical rule: if your chain is deeper than three, the call graph is the
// problem, not the token format.

Discussion

  • Be the first to comment on this lesson.