# HTTP connection pool credential isolation for AI agents

A reused HTTP connection is not automatically a credential leak. Treating it as harmless is how teams miss the actual failure: a client object accumulates cookies, challenge responses, redirect headers, proxy identity, or TLS identity, then a later request borrows that state under a different credential.

HTTP connection pool credential isolation means deciding exactly which state may cross requests and making the rest impossible to share. The destination host alone is usually too broad a boundary for an action runner or an agent that can act for several accounts. A fast client that occasionally sends the wrong account's cookie is worse than a slower client, because the error looks like a normal successful request.

## A socket is transport, not account ownership

A TCP connection carries bytes for an origin. It does not give an application a trustworthy account boundary. HTTP/1.1 reuses the connection sequentially, and HTTP/2 can run many streams at once, but neither protocol says that every request on a connection belongs to the same user or service account.

That distinction matters because client libraries often put unrelated concerns behind one pleasant interface called a session, agent, client, or transport. Developers then create one of those objects per host and add a bearer token before each call. The bearer header may be correct every time while a cookie jar, digest challenge cache, or proxy context quietly belongs to the last account that used the object.

RFC 9110 treats authentication as request and protection-space behavior. A client responds to a challenge for a particular origin, scheme, and realm. It does not say that an open connection owns a human or a service account. If your client makes that ownership assumption, the assumption comes from your code or its library, not HTTP.

Connection reuse remains desirable. It avoids repeated handshakes, reduces port churn, and helps a remote service under load. The correct rule is narrower: reuse a connection only among requests whose connection-level and client-managed state are intentionally compatible.

The states worth separating fall into two groups:

- Request state includes Authorization, Cookie, account headers, request bodies, and idempotency values. The caller must build these anew for every action.
- Connection and client state includes a pool entry, proxy session, client certificate selection, redirect behavior, cookies, challenge caches, and protocol settings. The client must scope or disable each of these deliberately.

A bearer credential used only in an Authorization header can share a TCP connection with another bearer credential if the library sends headers per request and keeps no account state. That is a limited claim, not a blanket approval. The moment the service also sets a session cookie, redirects to another host, or asks for a client certificate, the same pool design needs another look.

## Cookies are account state even when their names look harmless

A cookie jar is the most common source of accidental account crossover. Teams see a bearer token API and assume cookies are irrelevant, until a load balancer, interactive login endpoint, or legacy service adds `Set-Cookie` to a response. A general HTTP client usually stores it unless you tell it not to.

RFC 6265 defines how a user agent selects cookies by domain, path, security attributes, and related rules. Its storage model has no field for "the credential record that received this cookie." Two bearer credentials calling the same host and path can therefore qualify for the same stored cookie. The protocol follows its rules perfectly while your application crosses accounts.

Consider this trace from a test service. Account alpha uses a bearer token and receives an affinity cookie:

```http
GET /v1/whoami HTTP/1.1
Host: api.example.test
Authorization: Bearer alpha-token

HTTP/1.1 200 OK
Set-Cookie: route=alpha-node; Path=/; Secure; HttpOnly
Content-Type: application/json

{"account":"alpha"}
```

The next action selects beta. If a shared jar sends the stored cookie, the request carries two ownership signals:

```http
GET /v1/whoami HTTP/1.1
Host: api.example.test
Authorization: Bearer beta-token
Cookie: route=alpha-node
```

The best outcome is a service that rejects the mismatch. Less careful services route beta's request through alpha's sticky session, attach it to the wrong server-side context, or accept the header that happens to win. The client cannot rely on the remote service to rescue a mixed request.

For machine to machine APIs, my default is blunt: disable ambient cookie storage. If an API genuinely requires cookies, create a cookie jar for one credential record and one intended service context. Do not share it merely because the host string matches.

Also check request inputs before credential injection. An agent, plugin, or caller must not be able to supply its own `Cookie` header that survives into a credentialed call. Delete ambient authentication headers and cookies, then add only the headers the action definition permits. Otherwise you have built isolation around a door that callers can bypass.

## Authentication challenges need a current caller

A 401 response is not just an error code. It can invite the client to retry after selecting credentials, and that selection logic often lives outside the code that created the original request. Basic and Digest authentication handlers are especially prone to this pattern, but custom middleware can make the same mistake with bearer tokens.

A bad implementation keeps a mutable `currentCredential` on a shared client. Request A gets a challenge, the handler loads alpha's secret, and the client retries. Request B starts before the retry completes and changes `currentCredential` to beta. Now the challenge handler has a race that test suites miss because they run requests one at a time.

Do not repair that by placing a lock around a global credential field. The lock serializes work and still leaves the wrong object responsible for identity. Bind the credential record to the request context, carry it through every retry, and reject a retry if its context is absent.

Digest authentication deserves extra suspicion. It involves nonces, realms, counters, and a calculated response. Those values are tied to the challenged protection space, not to a generic shared client. Basic authentication is simpler on the wire, but a cache that automatically adds it to later requests still needs the same credential scope.

Use a test endpoint that returns a distinct realm or challenge for each account. Then run concurrent requests and assert that each retry uses the credential record attached to its own action. A test that only checks the final 200 status is too weak. Capture the request identity at the server and fail when alpha's challenge produces a beta authorization attempt.

Do not mistake a 403 for a challenge. Servers use 403 for many authorization decisions and clients should not treat it as permission to hunt for another credential. Automatic fallback across credentials turns an access failure into account probing, which is both unsafe and hard to audit.

## HTTP/2 makes ownership mistakes easier to hide

HTTP/2 lets several streams share one TLS connection. This is efficient, but it removes the old visual cue of one request waiting behind another. A client can send actions for alpha and beta at the same moment, and their headers remain separate only if the library models them as separate requests all the way down.

RFC 9113 bans HTTP/1.1 connection-specific header fields such as `Connection` in HTTP/2 requests. That rule does not make cookies or authentication account scoped. It only prevents a class of hop behavior from being expressed as ordinary request headers. Your client still owns cookie selection, retry selection, redirects, and any shared middleware.

HTTP/2 connection coalescing adds another wrinkle. Some clients can use one secure connection for more than one origin when certificate and name checks allow it. Coalescing does not replay an Authorization header by itself. It does mean that a pool identity based on a casual host comparison may not describe the connection the client actually chose. Keep origin authorization decisions separate from transport optimization, and test the library behavior you deploy.

Header compression also worries people for the wrong reason. HPACK compresses headers within an HTTP/2 connection, and QPACK does related work for HTTP/3. A compliant client does not decode a previous request's Authorization value into a later request. The danger is direct state reuse in your client code, plus possible metadata concerns in unusual threat models. Mark sensitive headers as never indexed when your library exposes that control, but do not call that a substitute for request isolation.

HTTP/3 changes the transport from TCP to QUIC. It does not change the ownership rule. A pool that lets a cookie jar or a credential cache float freely between actions remains wrong over QUIC.

Client certificates are different. A TLS client certificate is chosen during connection establishment, so it is genuinely connection-bound. Never multiplex different client certificate identities through the same connection or pool entry. Put the certificate record in the pool identity, or give each certificate a separate client instance. The same caution applies to a proxy that authenticates a connection before forwarding requests.

## Make the pool identity explicit in code

The pool identity should include every value that changes what a new connection can safely mean. For a bearer-only API with cookies disabled and no proxy identity, that may be the origin plus a credential record identifier. For a client certificate, proxy-authenticated path, or special TLS profile, include those identifiers too.

Do not use the secret text as the map identifier. It creates needless secret copies in memory and logs, complicates rotation, and tempts someone to print the map during debugging. Use an opaque credential record ID whose lifetime your vault or action registry controls.

This TypeScript sketch shows the shape. It uses Undici's `Pool`, but the boundary applies to any client library. The `credentialId` is an identifier, not the bearer value.

```ts
import { Pool } from "undici";

type Boundary = {
  origin: string;
  credentialId: string;
  proxyId?: string;
  clientCertificateId?: string;
};

const pools = new Map<string, Pool>();

function poolId(boundary: Boundary): string {
  return [
    boundary.origin,
    boundary.credentialId,
    boundary.proxyId ?? "direct",
    boundary.clientCertificateId ?? "none"
  ].join("\u001f");
}

function poolFor(boundary: Boundary): Pool {
  const id = poolId(boundary);
  let pool = pools.get(id);
  if (!pool) {
    pool = new Pool(boundary.origin);
    pools.set(id, pool);
  }
  return pool;
}

function headersFor(token: string, input: HeadersInit = {}): Headers {
  const headers = new Headers(input);
  headers.delete("authorization");
  headers.delete("cookie");
  headers.delete("proxy-authorization");
  headers.set("authorization", `Bearer ${token}`);
  return headers;
}
```

The sketch prevents a caller supplied Authorization or Cookie header from riding along with the injected credential. It does not implement a cookie jar, redirects, proxy dispatch, or certificate configuration. That omission is intentional: each of those needs a conscious decision instead of an accidental default.

If every credential talks to the same anonymous public endpoint, separate pools may be needless. If the endpoint sees different accounts, use separate pool entries until you have a specific reason and evidence to share. A pool is cheap compared with an account mixup.

Rotation needs its own rule. When a credential record changes, stop assigning new actions to its old pool. Let already sent requests finish under their original record if your action semantics allow it, then close that pool. Do not redirect an in-flight retry to the replacement record. A rotation changes authority; it does not repair the meaning of work already started.

A popular recommendation says to disable keep-alive everywhere. That reduces the number of shared transport objects, so it feels safe after an incident. It also conceals whether cookies, redirect code, and challenge caches are scoped correctly, while creating fresh handshake load for every request. Keep pooling, make its ownership explicit, and test the difficult paths.

## Redirects can send a clean request to the wrong place

Redirect handling is a second client hidden inside the first one. A library receives a 301, 302, 303, 307, or 308 response, constructs another request, and decides which headers survive. If that decision happens after your credential boundary code, it can carry an account header or cookie to an unintended destination.

For actions with injected credentials, start with redirects disabled or manual. Inspect the target origin before following one. Permit redirects only when the action definition expects them, the destination falls within its approved origin set, and the client rebuilds headers from the original credential context rather than copying an old header bag.

The status code matters. A 303 can change a request to GET in ordinary browser behavior, while 307 and 308 preserve method and body. A retrying client that treats them all alike can repeat a credentialed write at a different endpoint. That is an action integrity problem even when no secret crosses an origin.

Strip Authorization and Cookie headers on every cross-origin redirect. In many clients that is already the default, but defaults are not a test result. Verify the version and configuration you use. Same-origin redirects still need a path allowlist when a credential grants more authority than the original endpoint should receive.

Proxy authentication needs the same treatment. `Proxy-Authorization` belongs to the selected proxy path, not the origin service. Never place it in a generic default-header map. If two actions reach the same API through different authenticated proxies, separate their pool entries and make the proxy selection visible in the action record.

## A failed isolation test has a recognizable shape

Most teams test that alpha can call the API and beta can call the API. That proves credentials work. It does not prove that the same long-lived client can switch from alpha to beta without bringing luggage.

Build a small test server with two accounts and make it report what it received. It should return the account derived from Authorization, echo the incoming Cookie header, set an account-specific cookie, and expose a redirect endpoint. The service should reject a request when the cookie's account label differs from the bearer account label. The rejection makes the mistake visible instead of letting an affinity layer hide it.

Run this sequence against the exact client configuration used in production:

1. Send an alpha request through a fresh pool. Confirm that the response sets `route=alpha`.
2. Send a beta request through the pool lookup that production uses. Assert that the server sees beta authorization and an empty Cookie header, unless beta owns a separate jar that contains only beta cookies.
3. Start alpha and beta requests concurrently over HTTP/2. Make each endpoint pause before replying so their streams overlap, then assert the identities and cookies independently.
4. Return a 401 challenge to each account and verify the retry uses its original credential record.
5. Return a redirect to another origin and verify that the follow-up request has no authorization or cookie header.

The assertions should record more than status codes. Capture the selected credential record ID, pool ID, origin, protocol version, redirect target, presence of Cookie, and response account. Do not log the credential itself. When the test fails, those fields identify whether the leak came from pool lookup, ambient headers, a jar, or retry middleware.

Exercise credential rotation too. Start a request that waits at the server, replace the credential record, then issue a new request. The new action must use a new pool identity. Decide and document whether the waiting action completes with its original authority or gets cancelled. Both can be defensible; silently changing the authority mid-flight is not.

Run the test through any proxy configuration you support. Proxy state and origin state often live in different library layers, which makes this the place where a reassuring unit test turns into a useful integration test.

## Agent actions need a smaller authority surface

An autonomous coding agent should request an action such as "call this approved API using credential record billing-read". It should not receive a token and construct a broad, long-lived client that can keep session state around after the task changes. The action executor can bind the approved destination, method, credential record, and client boundary before it ever sends bytes.

Sallyport follows that separation by keeping API and SSH credentials in its encrypted vault and performing the action itself, so the agent receives the result rather than the credential. That containment is useful only if the HTTP path also treats each credential record as its own authority context.

Approval does not replace client isolation. An operator can approve a legitimate beta action while a careless shared cookie jar turns it into a mixed alpha and beta request. The approval record then documents an action that differs from the request the operator meant to allow. Put the boundary below the approval screen, where headers, retries, and transport selection actually happen.

For an action gateway, record the credential record ID and the pool identity in the audit event, but never the secret. If a caller reports an account discrepancy, you need to establish whether the executor selected the right credential and whether it attached only the state that record owns. A tamper-evident activity trail helps investigate that question; it does not make an ambiguous client design safe.

The first practical change is simple: find every shared HTTP client and list what it remembers beyond an open connection. If the answer includes cookies, challenge responses, redirects, client certificates, or proxy identity, put an explicit owner on each item before the next token rotation or concurrent agent run makes the bug expensive.
