Skip to content

One Login, Many Users: How Cached Tokens Leak Across Sessions in Agentic AI

9 min read Fullmakt Team

  • authentication
  • credentials
  • agents
  • traceability
  • governance
  • data-ownership

Every performance guide for building agent middleware says the same thing: don’t re-authenticate on every call, reuse the client, keep the connection warm. It’s correct advice, and it’s been correct advice since long before agentic AI existed — a service that logs in once and reuses the session for a thousand requests is doing exactly what a well-behaved HTTP client should. The advice quietly stops being safe the moment the thing being cached isn’t a service’s own credential, but a specific end user’s login handshake, and the “thousand requests” it gets reused for don’t all belong to that user.

The failure: a handshake that authenticates the process, not the request

Picture a support copilot that connects to each customer’s own helpdesk account — a real OAuth login, scoped to that one customer’s tickets — so the agent can act with that customer’s actual permissions instead of one shared service key. That’s the right instinct: it’s closer to data ownership than a single API key shared across every tenant ever gets. The agent middleware does the OAuth dance, gets back an access token scoped to Customer A, and — because someone read the performance guide — holds onto it in a client object that lives for the life of the worker process, not the life of the request.

Now a second request lands, seconds later, on that same warm worker, concerning Customer B. If the code path that decides “do I have a live token already?” checks anything coarser than the exact requesting user — the worker’s own identity, a connection-pool key that only encodes which helpdesk product is being called, a cache slot indexed by workspace instead of by session — it finds Customer A’s still-valid token sitting right there and uses it. Nobody forged anything. No credential leaked outside the system. The login handshake worked exactly as designed for the person it authenticated. It just got handed to someone else’s request because the cache didn’t know those two requests were supposed to be two different people.

Why this isn’t a bug you catch with more testing

This failure doesn’t show up in a functional test, because a functional test exercises one user at a time, in order, and the cache behaves correctly under exactly that load. It shows up under concurrency — two requests for different customers landing on the same warm worker close enough together that the second one reads a cache entry the first one just wrote — which is precisely the traffic pattern production sees and a test suite usually doesn’t. It’s the same category of gap we’ve described in parallel sub-agent fan-out: a design built around “one caller, one credential, one call at a time” meeting a runtime that’s never actually that serialized, except here the race is between two different end users instead of two branches of the same task.

It also hides behind abstraction on purpose. Agent SDKs and HTTP client libraries exist specifically to make “attach the right credential to this request” invisible to the code calling them — that’s the whole point of a client object. The cost is that the scope of “the right credential” is decided once, wherever that client gets constructed, and every call site downstream trusts that decision was made correctly for the request it’s currently handling. In a normal backend service, the client is usually scoped to the service’s own identity, so reuse is harmless — the service is always the service. An agent wrapper that authenticates as the end user breaks that assumption without anyone changing a line of the caching logic, because the code was copied from a pattern where it was always correct.

The same shape shows up at the protocol layer, not just inside custom middleware. An MCP server or an A2A endpoint fielding calls on behalf of many different end users through one running process has the identical trap available to it: if the executor resolves “which credential backs this tool call” from anything less specific than the actual authenticated principal on this request — a memoized lookup, a per-process default, a cache warmed by whichever request happened to run first — the protocol’s own login handshake becomes decoration. The handshake proved who was calling. The cache decided whose access actually got used, and those stopped being the same answer somewhere between the two.

Where the fix actually has to live

  • Credential resolution has to be keyed by the full requesting principal, every time — not by connection, not by worker, not by workspace alone. If two different end users can ever produce the same cache key, that key is too coarse, no matter how much latency splitting it further costs.
  • Reuse the connection, never the identity. Pooling a TCP connection or an HTTP/2 channel for performance is fine; pooling the bearer token that travels over it is a different decision that needs its own, much stricter, scoping rule — the two should never be bundled into one “keep this client warm” cache.
  • Warm-worker reuse needs an explicit boundary between requests. A serverless or thread-pool worker that survives across invocations has to clear any per-request credential state before picking up the next request, the same way it clears any other request-scoped variable — treating a cached token as just another object that outlives its request is exactly the bug.
  • The audit trail needs to record which principal a resolved credential was minted for, on every single call — not just that a call happened. That’s the only way a bleed shows up as a detectable anomaly (“this resolved value was used under two different requesting identities”) instead of two clean-looking log lines that each individually pass review, the same observability-vs-traceability gap we’ve described for multi-agent chains generally.
  • Nothing should be cached at all further upstream than the decision to dispense it. A decision token minted for one specific call and consumed once removes the whole category of “was this token still meant for the request that’s using it” — there’s no long-lived object sitting in a client to accidentally hand to the wrong caller.

The business case: pooling connections without pooling identity

This is precisely why Fullmakt never lets an agent’s own code hold or cache the credential it’s using, regardless of how the agent framework pools its HTTP connections:

  • Resolution happens fresh at the moment of execution, gated by a short-lived decision token scoped to that exact call. There’s no client-side cache to warm and no connection-pool key to get wrong, because the agent process never holds a plaintext secret long enough for a second, unrelated request to find it sitting there.
  • Every credential reference is bound to a principal and a task at issuance, so the resolved value that comes back for Customer A’s request cannot be the same object a concurrent request for Customer B picks up — there is no shared slot between them to collide on in the first place, the same owner-scoped binding we’ve described for tenant-crossing reads.
  • The audit log ties each resolved reference to the specific call and requesting principal that consumed it, so “did any two requests ever share a resolved credential” is a query against the trail, not a forensic reconstruction after a customer notices their data showed up somewhere it shouldn’t have.
  • Connection reuse and credential issuance are deliberately separate concerns — the broker sits at the point where a secret is dispensed, not inside whatever transport-level pooling the agent framework does for speed, so optimizing the latter can never silently widen the blast radius of the former.

The commercial logic is the same one behind every credential-governance gap in this space: the “reuse the client for speed” advice is correct and worth following, right up until the object being reused is the thing that decides whose data a request can touch. Move that decision to a broker that mints fresh, scoped access per call, and an agent framework’s engineers are free to pool connections as aggressively as they want — because pooling a connection was never the same thing as pooling an identity, and only one of the two was ever the actual risk.

FAQ

Isn’t this the same as the tenant-crossing support agent you’ve written about before? It’s a related outcome — a request ending up authorized as the wrong customer — but a different mechanism. The tenant-crossing case is a scoping decision: one API key was deliberately shared across every tenant. This is a caching bug: per-user tokens exist and are correctly scoped at issuance, but an overly coarse cache key or a warm-worker artifact hands one user’s already-issued token to a different user’s request.

How would a team even notice this is happening? It rarely shows up as an error — the call succeeds, because the token is valid, just for the wrong person. The signal to look for is an audit trail that can answer “was this resolved credential ever used under more than one requesting identity,” which requires logging the principal at resolution time, not just logging that a call was made.

Does this only affect OAuth tokens tied to a specific human user? It’s sharpest there, because that’s where “wrong person’s access” has an obvious victim. But the same caching mistake applies to any per-principal credential — an agent-scoped API key, a tenant-scoped database role — any time the code assumes a cached credential object is safe to reuse across calls that don’t actually share a principal.

Is the fix just “don’t cache anything”? No — connection pooling and warm workers are still good engineering. The fix is narrower: never let the pooled object also be the thing that determines whose access a request runs with. Keep transport-level reuse and credential resolution as two separate decisions, and scope the second one to the exact request every time.

Does resolving credentials per call instead of caching them cost noticeable latency? It adds a lookup, not a login — the decision-token model this depends on is designed to be a fast, local check, not a repeat of the original OAuth handshake. The alternative — caching the result of that handshake somewhere a different request can reach it — is the more expensive failure mode by far, just paid later and by someone else.

A login handshake is only as good as the code path that remembers who it was for. An agent framework can get every part of that handshake right — real user consent, a correctly scoped token, a clean OAuth exchange — and still leak one person’s access into another’s session, because the bug was never in the handshake. It was in treating a per-user credential like the kind of thing that’s always safe to keep warm.