Agents Honestly
Part XXI · Pattern CatalogSecurity Patterns

Identity Propagation

Carry the user's identity to every tool so the agent can never over-read.

Exercise

Problem

The agent authenticates the user correctly, and then calls every backend with its own service credential.

Nothing is broken. The session is valid, the token was checked, the login worked. And every question any user asks is answered by a principal with universal read, because the credential in the deployment config was provisioned during the pilot so the indexer could see everything.

The result is the defect that survives every security review: a marketing employee asks about compensation policy and gets a correct, helpful answer from an HR document they were never entitled to read. No exception, no 403, no access log entry anyone would question. The leak is laundered by generation.

The second-order version is worse because it looks like a fix. A team that notices this filters the results by the user's access before showing them. Authorization now happens, and the restricted content was still read out of storage, still passed through the process, still entered the traces, and still went to the model, where it can end up in a summary, a memory write, or a cached intermediate that the filter does not follow.

Forces

  • The agent legitimately holds more access than any single user. That is what makes it useful and what makes it dangerous.
  • Backends enforce what the token says, so a token that says "the service" gets the service's rights.
  • Accountability and authorization are different requirements. Impersonation satisfies one and destroys the other.
  • Runs outlive sessions. Agents pause for approval, resume days later, and run on schedules with no user present.
  • Rights change during the pause, and a resumed run must not replay stale authority.
  • Identity that is optional will be omitted by a background job written in a hurry.

Solution

Every backend call carries a token naming two principals: whose rights apply, and who is exercising them.

   {
     "sub":   "user:8841",              ← whose rights apply
     "act":   { "sub": "agent:atlas" }, ← who is exercising them
     "azp":   "app:support-console",    ← which client started this
     "scope": "orders:read credits:write",
     "exp":   <minutes, not months>
   }

   ── the backend authorizes on `sub` ──────────────────────────────
      a query the user could not run returns nothing, at the source

   ── the backend audits on both ───────────────────────────────────
      "atlas did this for user 8841 during run 4471"
      not "the service account did it"
Delegation, not impersonation. The backend authorizes on sub and audits on both.

Four rules:

Downscope on the way in, never filter on the way out. The token narrows the query at the backend, so restricted rows are never read. Filtering afterwards leaves the content in your process, your logs, and the model's context, and makes the backend's own audit log disagree with your policy, which matters because it is the system of record.

Re-derive at the moment of the action. Store the delegation reference, which user and which grant, in the run's state, never the token. On resume, mint a fresh one and treat a minting failure as a legitimate outcome that escalates rather than an error that retries. A run resuming Thursday with Monday's rights is a revocation that silently did not happen.

Make it structural. No client constructor without a delegation. Identity that can be forgotten will be forgotten: by the eval harness, the cache warmer, or the nightly backfill, which are precisely the paths nobody threat-models.

A scheduled run with no user still needs a named principal. A service identity with a documented human owner, a recorded grant, an expiry that forces re-approval, and a mined scope. "No user is present" is where least privilege quietly evaporates.

Code

ts/src/security/delegation.ts
export interface Delegation {
  userId: string;        // sub — whose rights apply
  agentId: string;       // act.sub — who is exercising them
  runId: string;         // for the downstream audit log
  scope: string[];       // the mined minimum, not the integration's grant
}

// Minted per call, expiring in minutes. NEVER checkpointed.
export async function mintToken(d: Delegation): Promise<string> {
  return exchangeToken({
    subject: d.userId, actor: d.agentId, scope: d.scope, lifetimeSeconds: 300,
  });
}

// There is no zero-argument constructor, and the raw client is not exported.
// A background job that wants a client must name a delegation, which forces
// someone to answer "as whom?" before the code compiles.
export function backendClient(d: Delegation) {
  return new Backend({ tokenProvider: () => mintToken(d) });
}

// On resume: re-derive, never restore.
export async function resumeDelegation(
  ref: DelegationRef, ctx: RunContext,
): Promise<Delegation> {
  const grant = await lookupGrant(ref.userId, ref.grantId);   // live check
  if (!grant.active) throw new EscalateToHuman('grant revoked during the pause');
  return { userId: ref.userId, agentId: ATLAS, runId: ctx.runId, scope: grant.scope };
}

resumeDelegation raising rather than returning a stale token is the line that makes the pause safe. It is also the line most likely to be removed by someone debugging a resumed run at 2am, so it belongs in a failure-injection test: revoke the grant mid-pause and assert the run escalates instead of proceeding.

Trade-offs

A token exchange per call. Milliseconds, and a dependency on the identity provider in the request path. Cache within a run for the token's short lifetime; do not cache across runs, and do not extend the lifetime to avoid the round trip.

Backends must accept delegated tokens. Internal services that only understand a shared secret need work before this is available, which is the real reason teams stay on service credentials. Do the highest-blast-radius backend first rather than waiting for all of them.

Latency and complexity on the pause path. Re-deriving on resume means a resumed run can fail for a reason unrelated to its work. That is correct and it needs a clear message: "the approver's grant was revoked" is actionable, and "401" is not.

Scheduled work is genuinely harder. There is no user to delegate from, so you are inventing a principal. Doing it honestly, with a named owner, an expiry, and a mined scope, is more work than reusing the service account, and it is the difference between a reviewable grant and an invisible one.

When not to use it

Single-tenant, single-user internal tools. If everyone who can reach the agent already has identical access to everything behind it, the token adds ceremony without a boundary. Revisit the moment a second access level exists.

When the backend has no authorization model. Propagating identity to a service that ignores it produces a false sense of enforcement. Fix the backend or put the enforcement in the dispatcher, and be honest about which you did.

For genuinely public data. A public documentation corpus needs no principal. Keep it in a separate index so nobody has to reason about whether the filter applied.

Impersonation is the trap, and it looks like the fix

The intuitive repair for the opening scene is to call backends as the user: same token the browser had. Authorization becomes correct immediately.

And accountability is destroyed. The backend's audit log now records user 8841 performing four hundred queries at 03:00, with nothing indicating an agent was involved. Incident response cannot tell automated activity from human activity, the user cannot dispute actions they did not take, and the compliance conversation has no answer to which system did this.

The act claim exists precisely to avoid this trade. RFC 8693 has supported it since 2020. You do not have to choose between authorizing correctly and knowing who acted.

References

On this page