Skip to content

14 min read

Forwarding the session cookie gives the agent everything at once

On this page

An agent that acts for a signed-in user needs some credential to present to the services it calls, and the web application it lives behind already holds one. Reusing that one is the shortest path from a working feature to a shipped feature, and it is the most common integration in production. This chapter is about what that reuse costs, why the cost is invisible in review, and what the minimal correct alternative looks like on a system that is already carrying traffic.

The pull request is eleven lines and it is genuinely good work. The assistant needed to call internal APIs as the signed-in user, the web app already had that user's session, and the author wired the two together in the obvious way:

ts
// app/api/agent/route.ts
export async function POST(req: Request) {
  const cookie = req.headers.get("cookie") ?? "";
  return runAgent({
    messages: await req.json(),
    toolContext: { authHeaders: { cookie } },   // tools forward this verbatim
  });
}

Every tool the agent calls now attaches that header, every internal service accepts it because it is exactly what a browser would have sent, and the feature ships. The tests pass, the permissions are right in the sense that the user cannot do anything through the agent that they could not do through the UI, and nobody in review has an objection they can articulate beyond a feeling.

Where the string goes after it enters the loop

The objection is that the header is one string with an enormous amount of authority attached, and it has just been moved into a system whose defining characteristic is that it copies strings around. Trace where it goes, one hop at a time, because each hop is an ordinary feature of an agent framework rather than a mistake.

It is a field in toolContext, which most agent frameworks serialise into the run record, since a run record that omitted the context could not be replayed. It appears in the arguments of every tool invocation, because that is how the tool receives it. Tool invocations are what tracing tools capture, by design — the argument list is the most useful thing a trace of an agent can contain. A typical setup keeps traces for 90 days in a third-party observability product, which means the cookie now lives in a system your security review never covered. If a tool echoes its own request for debugging, the cookie enters the model's context window, and from there it goes to the model provider and into any transcript you store.

None of those hops is avoidable by being careful, which is the part that matters. Each one exists because somebody wanted runs to be replayable, tools to be debuggable and traces to be complete, and every one of those is a property you want. The credential is copied because copying is what the system does with everything else in the tool context. And the copies persist: on a rolling 30-day session, the copy captured on day one is still valid on day twenty-nine.

What a leaked copy buys, in each case

The concrete question to put to a teammate is not whether the design is secure, which invites a yes-or-no argument nobody wins. It is what an attacker holding one captured string can do with it, and for how long.

That question has a specific answer here because of a property both credentials share. Both are bearer credentials, meaning whoever holds the string may spend it and no further proof of identity is asked for, so a copy that turns up anywhere can be replayed — presented again by whoever found it, and honoured. A bearer credential has no notion of who is presenting it, which is exactly why the question "what does one copy buy" is well posed: the answer does not depend on the attacker at all, only on what the string itself permits. That question has a specific answer for a session cookie and a different specific answer for a delegated token, and the gap between the two answers is the whole argument.

Forwarded session cookiePer-task delegated token
Valid againstEvery service that trusts the session — 14 internal APIs in a typical mid-size estateOne audience
OperationsEverything the user may do, including account settings and MFA changesThe scopes this task's tools declare
LifetimeRolling 30 days from last use300 seconds
Replay window after capture in a traceUp to 30 daysExpired before the trace is written
RevocationSign the user out of everything, including their browserNothing to revoke; it expires
Blast radius of one prompt injectionThe user's whole accountThis task's operations on this task's resources
Shows up inTool args, run records, traces, model contextTool args, run records, traces, model context

The last row is the one that makes the argument land, because the leakage paths are identical. Read the table as a gap between the columns rather than as a list of improvements: six rows show a difference and the seventh shows none, and the seventh is the one that says which kind of change this is. Nothing about moving to a delegated token stops secrets from being copied into logs; agents copy strings, and that will not change. What changes is what the copy is worth.

The replay row carries a condition worth stating rather than reading off. "Expired before the trace is written" holds when the trace is read some time after the run, which is the normal case for an observability store somebody queries days later. A token captured and replayed during its own run is still a working credential for the remainder of its five minutes, and that is exactly why the mint is per run rather than per session — the window it leaves is the run's own duration and nothing more.

This reframing is also what makes the conversation productive with the author of the eleven-line PR, because it does not require them to accept that they did something wrong. Their design has a working credential in the right place, and the change being asked for is one hop of indirection at the boundary, not a rewrite.

The exchange happens on the server, once

The minimal fix keeps the cookie exactly where it already works, which is between the browser and the app server, and adds a single server-side step that turns it into the bounded credential of the previous chapter before the tool loop ever sees a credential.

Sequence: Browser → App server (POST /api/agent (session cookie)); App server → A: verify session; A: verify session → user u_8831; App server → Token service (exchange: subject=u_8831, aud=billing, scope=invoices:read, ttl=300s); Token service → App server (access_token (5 min, one audience)); App server → Agent loop (run(messages, token)); Agent loop → Billing API (GET /invoices/inv_4419 (Bearer access_token)); Billing API → Agent loop (200).

The cookie stops at the app server. Read the diagram for what is absent from the arrows below the third one: no arrow from the agent loop back to the browser, and no cookie on any arrow the loop touches. What crosses into the loop is a token that was minted for this run, and the mint call is the place where all four bounds from chapter one get filled in, using facts the app server holds rather than facts the model produces. The app server is the correct place for that because it is the last component in the chain that has verified anything about the user; every component to its right is downstream of a decision it already made.

The wire format for that middle call is standardised as OAuth 2.0 Token Exchange, and using the standard grant matters mainly because the authorisation server, the resource servers and the audit pipeline all agree on the shape without a bespoke contract:

bash
curl -s https://auth.internal.example.com/oauth2/token \
  -u "$AGENT_CLIENT_ID:$AGENT_CLIENT_SECRET" \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token="$INTERNAL_USER_ASSERTION" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
  -d audience=https://billing.internal.example.com \
  -d scope="invoices:read refunds:write" \
  -d resource=https://billing.internal.example.com/accounts/acct_88213
json
{
  "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjJ...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300
}

Note the subject_token field: it is not the cookie. The app server verifies the session in the way it always did, then produces a short-lived internal assertion — a signed statement, readable by the token service, saying which user it just verified and when — and exchanges that. Sending the raw session cookie to the token service would move the same over-broad string one hop further into the estate, which is the change with the smallest possible benefit: one more component holding a month-long credential, in exchange for nothing. The MCP specification's authorization profile describes the client side of this flow if you want the protocol detail; the property that matters here is that the app server, which already knows who the user is, is the only component that ever holds a credential capable of proving it.

Why aud is doing more work than exp

Expiry is the bound people implement first, and audience binding is the one that stops the failure mode that actually shows up. A token with aud: https://billing.internal.example.com is rejected by every other resource server, because each one checks that the audience matches its own identifier and returns 401 otherwise.

That check is a closing identity and it is worth stating in both halves. What it licenses is that possession of the token gives an attacker access to one service and no other, whatever else the token permits, because every other service refuses it before looking at the scope. What it leaves behind is everything inside that one service: the token is fully valid there for its whole life, and the residual is bounded by the scope and the resource fields rather than by the audience. Without the check, a valid token is a valid token everywhere it is presented, and "everywhere" includes services the agent was never supposed to touch.

The scenario is mundane. An agent has a legitimate tool that queries an internal search index, and the search service logs full request headers at INFO because someone was debugging pagination in 2024 and never turned it off. Those logs are readable by every engineer with production log access, which is 140 people. If the token in that header is audience-bound to the search service, someone who reads it can query the search index as that user for the next four minutes, which is a real problem and a bounded one. If it is not audience-bound, the same string is a working credential against payments, the admin console, and anything else that accepts the issuer's tokens — one verbose log line in the least sensitive service becomes access to the most sensitive one. The same reasoning applies to a compromised third-party MCP server: it holds a token it was legitimately given, and audience binding is what stops it from spending that token somewhere else.

Short lifetimes interact with audience binding in a way that is easy to get backwards, and the reason is a property of how these tokens are checked. Resource servers validate them offline, without a call to the issuer, which is what makes validation cheap and what makes the tokens not revocable in any practical sense: there is no moment at which a server asks the issuer whether this token is still good. The expiry is therefore the revocation, and it is the only one. That makes the temptation obvious when a long-running agent hits an expired token mid-run, which is to mint with a longer TTL, the time to live written into exp. The move that preserves the property is to re-mint, from the app server, with the same bounds and a fresh clock, so an interrupted run picks up a new five-minute credential rather than one run's worth of thirty-minute credential. Re-minting also gives you a natural place to notice that the user's session ended while the agent was working, which a long TTL would have hidden, since the app server has to look at the session again in order to mint.

There is a subtlety worth naming, because it defeats the control quietly. If your authorisation server issues tokens with aud: internal-apis and every service accepts that value, you have implemented audience binding as a field and not as a boundary. The check still runs, the field still matches, and the token is still accepted everywhere — which is the outcome audience binding exists to prevent, reached through a correctly implemented comparison. The audience must be as specific as the trust boundary you care about, which usually means one value per service, and occasionally one per service-and-tenant.

Patching an integration that is already carrying traffic

Doing this to a live system is a different job from designing it, because the internal services on the other side currently accept cookies and know nothing about bearer tokens. The order that keeps the change reversible is to add token acceptance before you remove cookie acceptance. Each resource server learns to validate the new tokens — signature, issuer, audience, expiry, scope — while continuing to accept sessions, and it logs which of the two credentials each request arrived with. Running that for a week produces the list you actually need, which is every call path the agent makes and the scope each one requires, derived from traffic rather than from someone's memory of the tool manifest.

Then flip the app server to exchange, and expect a wave of 403s in the first hour, because the declared scopes will be missing at least a few operations that some tool quietly relies on — a GET /accounts/{id} that the refund tool calls to render a confirmation, most commonly. Those are easy to resolve when the resource server's denial includes the scope it wanted, and painful when it returns a bare 403, so it is worth making denials descriptive before the cutover. The last step is to stop accepting sessions on internal APIs, which is what converts the work from an improvement into a boundary; until that step, the old path is still there and something will keep using it.

Budget for it honestly. In a codebase with a dozen internal services this is roughly a two-week piece of work, dominated by scope discovery rather than by the exchange itself, and the runtime cost added to each agent run is one mint call. Because the mint happens once per run rather than once per tool call, a ten-step run pays it once, and a p99 of 40 ms against a run that already spends several seconds in model inference does not show up in any dashboard the product team looks at.

A compromised app server, and the missing principal

An attacker who compromises the app server during a live session can request an exchange themselves, so this is not a defence against a compromised trusted component; it is a defence against copies of credentials leaking out of components that are working correctly. That distinction is worth holding because it tells you where the remaining risk sits: the exchange concentrates credential-minting authority in one place, which is an improvement when that place is small and audited, and a worse outcome than the cookie if it is not.

It also does nothing about the agent being fooled, since a correctly scoped token spent on a correctly scoped operation for the wrong reason is still spent, which is why chapter five moves the decision to the moment of the action.

The larger gap is one the exchange creates by looking finished. The token minted above has sub set to the agent's client identity, and the human's identity was consumed by the app server during verification and then discarded. The billing API receives a request it can prove came from a legitimate agent with permission to read invoices for account acct_88213, and it cannot tell whether the person behind it was that account's billing owner, a support engineer, or someone whose entitlements were revoked twenty minutes ago.

So the exchange buys exactly what its two fields say and no more. The cookie no longer travels, so a captured copy is worth one service for five minutes rather than an account for a month, and the leakage paths that produced the copy are untouched and did not need to be touched. What has not been established is who the agent is acting for. Every service downstream of the exchange now needs the human principal, in a form it can verify rather than a form the agent could have typed, and that is what the next chapter puts on the wire.

Agent identity and delegated authority — read the current version at https://daviddominguez.dev/learn/en/agent-identity-and-delegated-authority/forwarding-the-session-cookie-gives-the-agent-everything-at-once

Back to top