kagent 0.10 Ships Audience-Bound Agent Tokens, Off By Default
Originally published at webofmike.com on 2026-09-08. The demo repo and every command in it were run before publishing. kagent v0.10.0 went GA on September 4. Buried in a long release list is a pair of environment variables that decide whether an agent's credential works at one backend or all of them: KAGENT_STS_RESOURCE and KAGENT_STS_AUDIENCE . Both default to empty. I built a three-container harness that shows exactly what changes when you set them: themsquared/kagent-sts-audience. No cluster, no cloud account, no provider key. It runs in about a minute. The problem an unscoped token creates A kagent agent authenticates as a Kubernetes service account, then exchanges that identity for a token it presents to a backend. The exchange is RFC 8693 OAuth 2.0 Token Exchange, and kagent has shipped a client for it for a while. The question the exchange has to answer is: a token for what? If the request does not name a target, the STS has nothing to scope the result to, and what comes back is good at every backend that trusts that STS. Your agent has one MCP server for reading GitHub issues and another for moving money. One prompt injection, one poisoned tool description, and the token it holds for the first is also the token for the second. This is the confused deputy problem with an agent in the deputy seat, and it is worse than the classic version because the agent is explicitly designed to take instructions from text it reads at runtime. Two weeks of agent-security stories have made that point repeatedly. What changes here is that the fix is now a config field. RFC 8707 is the standard answer: name the target resource in the exchange, get back a token whose aud claim is that resource, and let the backend refuse anything else. What v0.10.0 actually wires up Three pieces, all readable in the tagged source: The variables are registered in go/core/pkg/env/kagent.go , with descriptions that name the RFCs outright: KagentSTSResource = RegisterStringVar( "KAGENT_STS_RESOURCE", "", "RFC 8707 resource indicator sent on STS token-exchange requests to scope the issued token to a target backend.", ComponentAgentRuntime, ) They are read at runtime startup in go/adk/pkg/runner/adapter.go and split on commas, because both parameters are repeatable: resource := splitCSV(os.Getenv("KAGENT_STS_RESOURCE")) audience := splitCSV(os.Getenv("KAGENT_STS_AUDIENCE")) And they go on the wire in go/adk/pkg/sts/client.go , one form field per entry: for _, r := range req.Resource { data.Add("resource", r) } for _, a := range req.Audience { data.Add("audience", a) } Note what that loop does when the slice is empty: nothing. No field, not an empty field. That is correct behavior, since an empty resource would be an invalid request, but it is also why a misconfiguration here is silent. More on that below. Watching it work The repo runs the same agent three times against the same STS, changing only KAGENT_STS_RESOURCE . The agent is a stand-in for the kagent runtime, not kagent itself: it reads the same variables, applies the same comma-split, and sends the same form fields, each mirrored from the source above. Running kagent proper needs a cluster and a real IdP, which is a much longer path to the same observation. git clone https://github.com/themsquared/kagent-sts-audience cd kagent-sts-audience ./demo.sh Two backends are listening. mcp-github accepts only tokens whose audience contains https://mcp.internal/github ; mcp-payments wants https://mcp.internal/payments . Unset. The exchange sends no scoping parameters, and the token is bound to nothing either backend will take: KAGENT_STS_RESOURCE=(unset) scoping params sent: NONE issued token aud: ['urn:kagent:unscoped'] [FAIL] github -> HTTP 401 audience mismatch: token aud=['urn:kagent:unscoped'], this server is https://mcp.internal/github [FAIL] payments -> HTTP 401 audience mismatch: token aud=['urn:kagent:unscoped'], this server is https://mcp.internal/payments Scoped to one backend. This is the whole point. Same agent, same STS, same subject identity. The token works where it was meant to and is refused everywhere else: KAGENT_STS_RESOURCE=https://mcp.internal/github scoping params sent: ['resource'] issued token aud: ['https://mcp.internal/github'] [OK ] github -> HTTP 200 tool call accepted [FAIL] payments -> HTTP 401 audience mismatch: token aud=['https://mcp.internal/github'], this server is https://mcp.internal/payments That 401 at payments is the property you are buying. An agent that gets talked into calling the wrong backend now fails at the door instead of succeeding with a credential it should never have been holding. Scoped to both. Worth running, because it shows the knob has two directions: KAGENT_STS_RESOURCE=https://mcp.internal/github,https://mcp.internal/payments scoping params sent: ['resource', 'resource'] issued token aud: ['https://mcp.internal/github', 'https://mcp.internal/payments'] [OK ] github -> HTTP 200 [OK ] payments -> HTTP 200 Broad scope is still available. It just has to be asked for, in a config file, in a pull request someone can review. That is a meaningfully different posture from broad scope being what you get by not typing anything. Why the unset case fails open Scenario 1 is the one to think about, because it is what a typo produces. splitCSV returns nil for an empty or whitespace-only value. The loops in buildFormData then iterate an empty slice and add nothing. So KAGENT_STS_RESOURCE="" , KAGENT_STS_RESSOURCE=https://... , and simply forgetting the variable are all the same request on the wire: an unscoped exchange. Whether that lands as a hard failure or a silent over-grant depends entirely on the backend. In the demo the backends validate aud , so an unscoped token gets a clean 401 and you find out immediately. Against a backend that does not check aud , the same misconfiguration produces a token that works everywhere, and nothing anywhere reports a problem. The control is therefore two-sided, and only one side lives in kagent. Setting KAGENT_STS_RESOURCE is not worth much unless the backends actually reject the audiences they are not. Check both. Where this sits relative to the spec Worth being precise about what this is and is not. This is a runtime shipping a standard OAuth mechanism for scoping a token to a service. It is not agent identity. The token's subject is still the Kubernetes service account of the pod, and the act claim names the runtime. Nothing in the exchange distinguishes one agent session from another, or carries what the agent was asked to do. That gap is not kagent's to close alone, and the spec work is genuinely unfinished. I went through the MCP auth extensions repo in a separate post: of the four identity workstreams the August roadmap names, one stable spec has shipped, and DPoP and Workload Identity Federation are both still open with "How Has This Been Tested?" answered TBD. Audience-bound tokens are the part of this problem that has a finished RFC and a shipping implementation, which is a good reason to take the part that works. For the identity layer underneath, SPIFFE workload identity is the other half I would pair this with, and keeping the LLM key out of the agent entirely is the same argument applied one layer up. Turning it on On the agent runtime: env: - name: STS_WELL_KNOWN_URI value: https://sts.example.com/.well-known/oauth-authorization-server - name: KAGENT_STS_RESOURCE value: https://mcp.internal/github STS_WELL_KNOWN_URI is what enables the exchange at all. With it unset and token propagation on, adapter.go runs the plugin in propagate-only mode and performs no exchange, in which case the resource indicator has nothing to attach to. One caveat on that snippet: I read it out of the v0.10.0 source rather than applying it to a running cluster. The three demo scenarios above were all executed and their output is copied verbatim. The YAML was not, so treat it as unverified and check it against your own deployment. Two more limits worth stating plainly. Audience binding says which service may accept a token, not what the caller may do once accepted; tool-level authorization is a separate control. And the demo's backends check signature, expiry, and audience only. A real one should pin the issuer and verify against the STS's published keys. What I would do with this If you run kagent and your agents talk to more than one backend, the useful exercise is not adopting the demo. It is answering two questions about what you already have deployed: does the exchange name a resource, and would your backends notice if it did not. The code is at themsquared/kagent-sts-audience. It is Apache-2.0, dependency-free beyond a stock Python image, and the three scenarios are the entire argument. Frequently asked questions How do I scope a kagent agent's token to a single MCP backend? Set KAGENT_STS_RESOURCE on the agent runtime to the target backend's resource URI, for example https://mcp.internal/github. kagent v0.10.0 sends it as an RFC 8707 resource indicator on the RFC 8693 token exchange, and the STS returns a token whose aud claim is that resource. An audience-validating backend then rejects the token anywhere else with a 401. What is the difference between KAGENT_STS_RESOURCE and KAGENT_STS_AUDIENCE? KAGENT_STS_RESOURCE is an RFC 8707 resource indicator, a URI naming the target service. KAGENT_STS_AUDIENCE is an RFC 8693 audience, a logical name the STS already knows. kagent sends both as separate repeatable form fields and does not merge them, so set whichever your STS keys on. Setting neither leaves the exchange unscoped. Does kagent scope agent tokens to a backend by default? No. Both KAGENT_STS_RESOURCE and KAGENT_STS_AUDIENCE default to an empty string in kagent v0.10.0. An empty value is dropped rather than sent as an empty field, so the exchange goes out with no scoping parameters and the STS has nothing to bind the token to. Audience binding is opt-in configuration, not a default. Canonical version, with machine-re
Comments
No comments yet. Start the discussion.