Skip to content
Chapter 3 of 8 · TransportsCaller identity under HTTP

17 min read

Caller identity under HTTP

On this page

The entry point really does change by one line. A server that ran a stdio loop becomes an ASGI application object — ASGI being the standard interface between a Python web server such as uvicorn and an async web app — that something else is left to host:

python
# before
if __name__ == "__main__":
    server.run(transport="stdio")

# after
app = server.streamable_http_app()   # an ASGI app, mounted at /mcp

Everything above that line can be byte-identical: the SQL builder, the schema introspection, the result formatting, the tool descriptions. What the line changes is who the process is on behalf of. Under stdio the process is a developer — spawned by their client, running with their environment, their filesystem, their network position. Under HTTP one long-lived process answers everybody, and an inbound connection carries no statement about who opened it. Authentication stops being something the operating system did for you and becomes code you write, and the credential the stdio version quietly borrowed becomes a credential you have to provision on purpose.

Sorting the change list

It is worth being specific about which parts of the migration really are a flag, because the people making the "it's just a flag" argument are not wrong about everything, and the parts that are genuinely free are the reason the rest gets underestimated.

Concernstdiostreamable HTTPReally just a flag?
Message format and semanticsnewline-framed JSON-RPC on a pipethe same JSON-RPC in a POST bodyYes — identical bytes
Tool schemas and handler codeunchangedunchangedYes — one entry point line
Caller identityinherited from the parent processabsent until you establish itNo
Credentials used downstreamthe caller's own, from their environmentone process credential for everyoneNo
Session lifetimebounded by the client processindependent, must be created and expiredNo
Logsthe parent's stderr, one machine, one usermany callers interleaved in one streamNo

The two rows in the middle are where a half-day estimate goes. They are also linked, because the second is a consequence of the first: a process that cannot tell its callers apart has no basis on which to use different credentials for them, so it uses one.

That consequence is where the half-day estimate actually dies, and it is worth walking one step at a time, because it is not obvious that there is no third option. The process opens its downstream connections at startup, before any request has arrived, so whatever credential it holds was chosen without reference to any caller. A credential names a fixed set of permissions. Hundreds of callers have hundreds of different permission sets, and no single fixed set equals all of them at once unless everybody's grants happen to be identical. So the one set the process holds is either larger than somebody's, in which case that person's session can reach data they were never granted, or smaller than somebody else's, in which case their tool calls fail on data they are entitled to. There is no third arrangement available to a process that cannot tell its callers apart, which is why the credential row sits downstream of the identity row rather than beside it.

What becomes of the password in the environment variable

A stdio server takes its downstream credential the way every stdio server does, through the client's config block:

json
{
  "mcpServers": {
    "analytics": {
      "command": "analytics-mcp",
      "env": {
        "DATABASE_URL": "postgres://dana:${env:PGPASS}@warehouse.internal:5432/analytics"
      }
    }
  }
}

That connection string carries dana's own database role. When she asks the model to summarise last quarter's churn and it calls a run_query tool, the SQL executes as dana, so the grants the data team already maintains apply without anybody thinking about it: she can read analytics.events and analytics.subscriptions, and the SELECT against hr.compensation that a badly-worded prompt might produce fails with a permission error from Postgres, which is exactly the outcome you want. The access control was not implemented in the server; it was inherited, in the same way the filesystem was.

Deploy that same image to a shared host and the environment variable is still there, because it has to be — the process needs a connection string at startup and there is no user to take one from. Now it holds one role for hundreds of people. Whoever provisions it faces a choice with no good answer: give the service account the union of everybody's grants, in which case a marketing analyst's session can read the compensation table because someone in finance needed it; or give it the intersection, in which case the tool is useless to most of the people it was built for. The failure has no error message and no incident, and it converts one person's access into everyone's the moment the deployment succeeds.

The absence of an error is not luck, and following the chain that produces it is what makes the risk legible to somebody who has not already been bitten. A permission error is something the database raises when the role executing a statement lacks the grant the statement needs. Under the union credential the executing role has every grant anybody has, so the condition for raising the error is never met, so no error is raised. Nothing else in the path is watching either: the query succeeded, the tool returned rows, the model summarised them, and the audit trail records a statement run by the service account against hr.compensation, which is a true statement about an account that is entitled to do exactly that. Every layer reports success because every layer is being told the truth. The thing that changed is the question the system is able to answer, and no component was ever asked it.

The database is the vivid case; the same argument applies to every credential the stdio version inherited. A GITHUB_TOKEN scoped to one engineer's repositories becomes an organisation-wide token. An AWS profile that resolves to her SSO role becomes an instance role. A Kerberos ticket or SSH agent that simply was not available to a container becomes a service key with no expiry.

Establishing who is calling

The replacement is to make every request carry its own proof of identity, which under HTTP means an access token the server validates on arrival. The MCP authorization specification builds this on OAuth 2.1, the current revision of the standard in which a separate authorization server issues short-lived tokens on a user's behalf so that the service holding the data never sees their password. It treats the MCP server as a protected resource: an unauthenticated request gets a 401 whose WWW-Authenticate header points at the metadata document describing which authorization server to use, and the client then runs the normal flow and retries with a bearer token, a credential the server honours from whoever presents it, with no further proof of possession.

http
POST /mcp HTTP/1.1
Host: mcp.internal.example.com
Content-Type: application/json
Accept: application/json, text/event-stream

{"jsonrpc":"2.0","id":7,"method":"tools/call",
 "params":{"name":"run_query","arguments":{"sql":"select count(*) from events"}}}
http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata=
  "https://mcp.internal.example.com/.well-known/oauth-protected-resource"
Sequence: MCP client → MCP server (POST /mcp (no token)); MCP server → MCP client (401 + WWW-Authenticate: resource_metadata); MCP client → MCP server (GET /.well-known/oauth-protected-resource); MCP server → MCP client (issuer, scopes, resource identifier); MCP client → Authorization server (authorization code + PKCE); Authorization server → MCP client (access token, aud = the MCP server); MCP client → MCP server (POST /mcp + Authorization: Bearer …); MCP server → MCP client (200, subject = dana@example.com).

Every arrow in that diagram is work that did not exist under stdio, and most of it is the client's rather than yours — which is the part that makes the migration look cheap from the server side right up until a client that only implements bearer tokens from a static config file has to connect. The server's own obligations are the first and last arrows: refuse cleanly with a pointer to its metadata, and validate what comes back.

Two properties of that token matter more than the flow that obtains it. The first is that it must be issued for this server and the server must check that it was — the aud claim, which names the audience a token was minted for, or the resource indicator the client asked for. A server that accepts any token its identity provider signed will happily accept one minted for a different service, and a server that forwards a token it received to a downstream API is the confused deputy problem — a program holding more authority than its caller being talked into spending that authority on the caller's behalf — with extra steps; the specification forbids that passthrough explicitly. The second is that validation has to fail closed. A verify_token that swallows exceptions and returns None, combined with a handler that treats None as "no user restrictions", is an authentication system that authenticates nobody.

Both of those are easy to state and easy to implement in a way that does not hold, so it is worth being exact about what each check proves. A signature proves that the token was minted by the issuer whose key verified it, and nothing beyond that. The same issuer mints tokens for every service in the organisation with the same key, so a valid signature is equally consistent with a token meant for the analytics API, the wiki, or the deployment tool. The only field naming the intended recipient is the audience. A server that checks the signature and not the audience has therefore established that the token is genuine and has established nothing about whether it is for them, and the failure this produces is not a forged token at all — it is a real token, held legitimately by a client connected to several servers, presented to the wrong one.

Failing closed is the same care applied to the negative path, and it is worth naming why the open version gets written. An exception inside token validation looks like an availability problem: the issuer's key endpoint is slow, a library threw on a field it did not expect. The instinct that keeps a service up is to catch it and carry on, and carrying on means returning no subject. Meanwhile the handler was written in a world where there was exactly one user, so "no subject" reads there as "the ordinary caller, no restrictions". Neither half is unreasonable on its own. Composed, they produce a system that stops checking identity precisely at the moment its identity machinery is unwell, which is the moment you would most want it to refuse.

Turning a subject into downstream authority

Having established the caller, the server still has to translate that identity into downstream authority, and this is the part that no library does for you. There are three workable patterns, and the choice depends on how much of your existing access control lives in the downstream system:

python
@server.call_tool()
async def run_query(sql: str, ctx: Context) -> str:
    user = ctx.request_context.auth.subject        # "dana@example.com"
    async with pool.acquire() as conn:
        # 1. Reuse the database's own model: run as the caller's role.
        await conn.execute("set local role %s", db_role_for(user))
        # 2. Or push identity into row-level security predicates.
        await conn.execute("set local app.user_id = %s", user)
        return format(await conn.fetch(sql))

Setting the role is the strongest of the three, because it keeps the grants where the data team already manages them and the union-of-everything credential never has to exist; the process connects as a role that can SET ROLE to any user role and nothing more. Row-level security, in which the database attaches a policy predicate to every query so that a table returns only the rows a given user is entitled to, suits warehouses where access is per-row rather than per-table. The third pattern, exchanging the caller's token for a downstream one through an identity provider, fits when the downstream system is an HTTP API of your own rather than a database. What all three have in common is that the tool handler is now aware of a caller, and that is the concrete shape of the work the flag hid.

SET LOCAL ROLERow-level securityToken exchange
Where the grants livethe database, unchangedRLS policies you writethe downstream service
Needs a role per useryesnono
Granularitytable and columnrowwhatever the downstream offers
Suitsan existing warehouse with real rolesmulti-tenant tablesan HTTP API you own
Breaks whenroles are provisioned by hand and lag joinersa policy is missing on a new tablethe exchange endpoint is down, and every call fails closed
Added latency per callone round trip, ~1 msnoneone token exchange, 20–80 ms, cacheable per session

None of the three is a library you install. Each is a decision about where your existing access control already lives, and the migration is cheap in exactly the case where that answer is already "in the downstream system" and expensive when the honest answer is "in the fact that only one person ran this".

The state that was safe while there was one caller

Authenticating each request settles which credential the process uses and leaves a second class of bug entirely untouched, because a stdio server may cache anything it likes about "the user" in module scope and never be wrong to do so. analytics-mcp did that in three places: a schema introspection built on first use, the caller's default search path, and the last result set, kept so that a refine_last_query tool could work on it without re-running the SQL. Under stdio each of those globals belonged to one person for the life of the process. Under HTTP they belong to whoever wrote them most recently, so a refine issued by one analyst operates on another analyst's rows, and a schema cache built for someone with access to three schemas is served to someone entitled to one.

The connection-pool version of the same fault is harder to see, because the fix appears to be in place already. SET ROLE without LOCAL persists for the life of the connection, so a handler that sets the role and then returns the connection to the pool hands the next caller a session already running as dana. Behind PgBouncer in transaction pooling mode — a connection pooler that lends one backend connection to a client for the duration of a transaction and then lends it to somebody else — the distinction is sharper still: SET LOCAL inside the transaction is correct, and a bare SET is worse than having written nothing, since the connection is recycled the moment the transaction ends and the role rides along with it. What you observe is a low rate of wrong answers — roughly one query in a few hundred, scaling with pool churn and concurrency — with no error raised anywhere, and an audit trail that records the right subject on the HTTP request and the wrong role on the query.

The word doing the work in all of that is LOCAL, and naming what it means makes the whole family predictable rather than memorable. SET ROLE changes a setting on the session, and the session is the connection; a connection outlives any one transaction and any one HTTP request, so the setting outlives them too. SET LOCAL ROLE changes the same setting for the duration of the current transaction only, and the database puts the previous value back when that transaction ends. Whether the difference is harmless therefore turns on one question — is this connection ever used by anybody else — which under stdio is permanently no and under a pool is eventually yes.

The rate falls out of the same reasoning, and it is worth deriving because a trickle of wrong answers is much harder to take seriously than an outage. A wrong answer needs two things to line up: a connection has to be returned to the pool while still carrying somebody else's role, and the next borrower has to be a different person. Each of those is a fraction rather than a certainty, and the observed rate is their product, which is why the symptom is a trickle. It is also why the trickle grows exactly as the deployment gets busier: more concurrency means more borrowing, and more borrowing raises both fractions at once.

python
async def release(conn) -> None:
    await conn.execute("DISCARD ALL")     # role, search_path, temp tables, prepared statements
    await pool.release(conn)

Resetting on release costs about 0.3 ms and removes the whole family, and it is worth pairing with the discipline that identity is never ambient in this codebase: no module-level current user, no cache keyed on anything narrower than the subject, and any per-session state held in a dict keyed by the session id the transport issues. That rule is easy to state and awkward to retrofit, which is why an honest estimate for this migration includes an audit of every global the stdio version was entitled to keep.

Authentication, authorization and the perimeter

Having a verified subject on every request answers "who is calling" and leaves "what may they cause" entirely open. Authentication is not authorization: a valid token from a real employee still needs a policy decision about whether run_query may touch hr.compensation, and that decision belongs in the handler where the tool's semantics are known. And an authenticated caller is not the same as a trusted request, because the entity composing the arguments is a model acting on text that may include a retrieved document or another server's tool description. Dana's token on a request whose SQL was suggested by a poisoned README is still Dana's token. The final chapter of this topic takes that apart properly, since it is the same argument that undermines the comfortable reading of the stdio case.

Network controls are also not a substitute, though they are frequently offered as one. Putting the deployment inside the VPC, or requiring mTLS between the ingress and the pods so that both ends of every connection present a certificate, establishes that the request came from inside the perimeter and says nothing about which of the hundreds of people inside it sent it. Both are worth having; neither produces a subject you can put in an audit log or a SET ROLE.

The reason the substitution cannot be made to work is a mismatch in what the two things answer, and it is worth stating plainly because it is offered in good faith by people who are not confused about anything else. A firewall rule, a VPC boundary and a mutual TLS handshake all answer "where did this connection come from", and they answer it well — a mesh certificate is real evidence that the connection originated in a workload inside the cluster. SET ROLE and an audit entry need the answer to a different question, "which person is this on behalf of", and there is no way to compute the second answer from the first, because the whole purpose of a perimeter is that many people are inside it. A perimeter with one person inside it would be an identity. A perimeter with two hundred inside it is a location.

The single line at the entry point is therefore an accurate description of the code change and a misleading description of the work. What it removes is the parent process, and the parent process was supplying an identity, a credential, an access control model and an implicit guarantee that anything cached in module scope belonged to one person. Each of those has to be rebuilt explicitly: a token validated for this audience, a subject derived from it, a downstream authority derived from the subject, and per-session state keyed rather than ambient.

The observability row in the table earlier changes shape for the same reason. Under stdio the log was a single user's stderr on a single machine, and reading it in order was reading one session. Under HTTP the process writes one interleaved stream for everybody, so every log line needs the session identifier and the request id to be reconstructable, and a trace that starts at the client and ends at the warehouse has to carry context across an HTTP boundary that did not exist before. That session identifier is not a thing you invent; it is issued by the transport itself, along with the endpoint shape, the streaming rules and the resumption mechanism that the rest of this topic depends on.

Transports — read the current version at https://daviddominguez.dev/learn/en/transports/caller-identity-under-http

Back to top