Chapter 2 of 7 · 15 min read
Request-scoped credentials
On this page
Minting a credential per request is easy to write once and easy to lose everywhere else. The mechanism is a few lines of AssumeRole; the property is a claim about the whole system, and the property is what a second deployment, a new tool or a shared policy attachment quietly takes away. This chapter is about the parts that hold the property in place rather than the exchange itself.
The parts that hold it are boring in the way that platform work is boring, and they are worth the tedium for a second reason beyond containment: a shared service account cannot produce the log line that names the task, so under ambient authority the detection of a cross-tenant read is statistical, late and noisy, while under derived credentials it is a hard failure with a principal attached.
One identity per boundary, and the boundary is not the codebase
The unit of identity is a trust boundary, not a service, and the most common way that goes wrong is a second deployment of the same image. Take a renewal agent that summarises contracts for enterprise accounts, and a second one stood up for public-sector accounts whose data lives in a separate CRM tenant and a separate S3 prefix because of a data-residency commitment. Those two serve different data under different commitments; that is a boundary, and it needs its own role, its own trust policy, and its own set of statements. The image being identical is irrelevant, in the same way that two instances of Postgres running the same binary are not one database.
Written out, the second agent gets renewal-agent-pubsec, whose policy names only pubsec/* and whose trust policy admits only the renewal-pubsec Kubernetes service account in the pubsec namespace. The enterprise role keeps only contracts/*. The property this buys is stated in the negative and is the one worth holding onto: an attacker who fully controls one agent — every tool call, every argument, every credential it can obtain — reaches that segment's customers and nothing else. Not "reaches less"; reaches a set you can name in advance and write on a page.
Getting there by adding a statement to the existing role instead is the failure this chapter exists to prevent, and it is worth being clear about why a session policy does not save you. A session policy only narrows what the role already grants, so if the shared role can reach both segments, then anyone who can influence the code path that builds the session policy, or any tool that constructs a client without going through the minting helper, has the union of both segments available. Two agents, one ceiling, and the ceiling has gone up.
The two failures are worth separating, because a session policy handles one of them completely and the other not at all, and conflating them is how the shared role survives review. Inside a single segment it works: a run started for ACME mints a credential naming ACME's prefix, and an injected instruction to fetch Globex's master agreement is refused, because Globex's prefix was never in the intersection of the role and the session policy. That is the whole of the previous chapter and it holds. Across segments it does not, and the reason is structural rather than a matter of care. The session policy narrows downward from the role's ceiling, so raising the ceiling raises everything the narrowing can be talked into producing; and the narrowing is computed by code inside the agent process, which is the one process an injection is already influencing. So a single role covering both segments means the strongest statement available is "each run asks for one prefix", and the strongest statement you want is "no run can reach the other segment at all" — and only the second survives a code path that constructs a client without going through the helper.
There is a version of this that gets done for compliance and stops halfway, where the two agents get separate roles and then both roles are attached to a shared "agent-common" policy holding the database read, the Secrets Manager access and the SQS permissions, because those are the same for everyone. The common policy is where the ceiling migrates to. If a permission is in a shared attachment, the separation is decorative for everything that permission covers, and the honest test is to ask what is left in the intersection of the two roles' effective permissions and whether you would be comfortable with an attacker holding exactly that.
Deny-by-default is about what a new tool inherits
IAM is deny-by-default in the sense that an unlisted action is refused, which is the property everybody means when they say the phrase, and it is not the property that matters here. The relevant question is what happens when someone adds a tool — a new function in the agent's manifest, six months from now, written by someone who has not read this chapter. If that tool constructs its own client from the environment, it inherits whatever the role holds, and no review step will necessarily fire, because adding a function is not a permissions change in any system that watches permissions.
The way to make that visible is to force every tool to declare the capability it needs and to make the runtime refuse tools that have not declared one. The declaration is data, so it can be diffed, and a widening shows up in the pull request as a widening rather than as a new file.
# agents/renewal-pubsec/capabilities.yaml
identity: arn:aws:iam::4471…:role/renewal-agent-pubsec
default: deny
grants:
- tool: read_contract
action: s3:GetObject
resource: "arn:aws:s3:::acme-contracts-prod/pubsec/${task.customer_id}/*"
ttl_seconds: 900
- tool: lookup_renewal_date
action: rds-db:connect
resource: "arn:aws:rds-db:eu-west-1:4471…:dbuser:crm-pubsec/renewal_reader"
ttl_seconds: 900The ${task.customer_id} placeholder is the whole point of the file: a grant is not a resource, it is a template over the task, and it cannot be resolved without a task to resolve it against. A tool with no entry in grants gets a client that has no credentials at all and fails on its first call, in CI, on the branch that added it.
class CapabilityDenied(Exception):
pass
def client_for(tool_name: str, task: Task):
grant = GRANTS.get(tool_name)
if grant is None:
raise CapabilityDenied(
f"tool {tool_name!r} declares no capability; add it to capabilities.yaml"
)
return broker.mint(grant, task)The reason that failure lands in continuous integration rather than in production is worth following, because it is what the design is buying. An undeclared tool does not get a smaller credential or a default one; it gets a client with no credentials at all, and a client with no credentials fails on its first call rather than on an unusual one. First calls happen in the first test anyone writes, on the branch that added the tool, before it merges. Compare the alternative: a tool that constructs its own client from the environment succeeds on every call, including the first, and the only calls that would reveal the problem are the ones an attacker makes later. The manifest converts a silent widening into a loud absence, and the loudness is the point — the same widening has moved from something no reviewer can see to something no branch can pass without a line being added to a file whose diffs a human reads.
Where the credential is minted, and what that costs
Deriving the credential from the task means something has to hold the task, check that the requested grant is consistent with it, and perform the exchange. Putting that logic inline in the agent process works and has a weakness, which is that the code deciding "this run is for ACME-1042" sits in the same address space as the code an injected instruction gets to influence. A broker — a small sidecar or an internal service — that receives a signed task ticket and returns credentials moves that decision behind a boundary the agent cannot argue with.
The signature on the ticket is what stops the agent from asking for a customer it was not given. A ticket minted by the API gateway from the authenticated caller, signed with a key the agent container cannot read, means the strongest thing an injected instruction can do is ask the broker for a credential it is already entitled to.
The cost is measurable and small. sts:AssumeRole runs at roughly 35–60 ms for the median call — p50, in the notation the rest of this topic uses — from inside the same region, which is why the broker caches by (grant, task) for the credential's lifetime minus a sixty-second skew; a run that touches four contracts for one customer pays the exchange once, not four times. At 900-second credentials and a workload of 12,000 agent runs a day, that is 12,000 AssumeRole calls, comfortably inside the default STS rate and free of charge. The costs that actually bite are organisational: one role per segment per environment means role counts grow multiplicatively, IAM allows 5,000 roles per account, and a company that reaches four figures of roles has a naming and cleanup problem long before it has a quota problem. Teams that hit this move to session tags — key-value pairs attached to a temporary session that policy conditions can then test, so one role can serve many segments — or to a single role with a strongly parameterised trust policy, which trades a smaller role inventory for a policy document that fewer people can read correctly.
Two of those figures are the same figure and it is worth seeing why, because the identity is what makes the cost predictable on a workload you have not measured. The cache is keyed on the pair of a grant and a task, so a second request for the same pair is answered from memory and never reaches the Security Token Service; and the credential outlives the run, since fifteen minutes is longer than an agent turn takes. Put those together and the number of exchanges a run performs is the number of distinct grants it uses, regardless of how many calls it makes. A run that reads four contracts for one customer uses one grant four times, so it pays the exchange once and the amortised cost per contract is a quarter of the median — the exchange stops being a per-call cost and becomes a per-run one. Now take a workload of twelve thousand runs a day where each run uses a single grant: the exchanges per day are the runs per day, one for one, which is where the second twelve thousand comes from. That equality is conditional, and the condition is the interesting part. A run that calls both read_contract and lookup_renewal_date uses two grants and therefore mints twice, so the honest general form is exchanges equal runs multiplied by distinct grants per run, and the figure quoted is that formula with the multiplier at one.
Read as a rate the same number stops being alarming. A day runs to tens of thousands of seconds, so twelve thousand exchanges spread across one average out to well under one a second, which is why the quota is not the constraint and the invoice is not either. The constraint arrives from the direction nobody budgets for. One role per segment per environment means the role count is a product rather than a sum — segments multiplied by environments — and a product of two unremarkable numbers is large while both factors still look modest. That matters against the quota in a specific way: the limit is five thousand roles in an account, and four figures begins at one thousand, so the point at which nobody can name or retire a role reliably is around a fifth of the point at which the platform refuses to create one. The wall you hit is organisational and it is the one with no error message.
Minting is not the only way to attach authority to a request, and it is worth knowing why it usually wins for agents. The competing approach is to keep one connection or one credential and enforce the scope at the data layer — Postgres row-level security keyed on a SET LOCAL app.current_customer, or a policy engine that inspects each query. That works well and has two properties that make it a poor sole defence here. It only covers stores that implement it, so the moment the agent touches S3, a vendor API or a search index, the enforcement point is gone and you are back to hoping the calling code passes the right filter. And the variable that carries the scope is set by the same process the attacker is influencing, so an injected instruction that reaches a code path issuing raw SQL can reset it. Row-level security and derived credentials compose well — the credential decides which database user you connect as, the policy decides which rows that user sees — and the ordering matters, because only one of the two survives a tool that talks to something other than a database.
The sentence a shared service account cannot say
The second thing per-request credentials buy is a log line. When every call carries a session name derived from the task, the audit trail records not that the component acted but which unit of work it acted on behalf of, and that difference decides how long an investigation takes.
{
"eventTime": "2026-03-11T09:14:22Z",
"eventName": "GetObject",
"errorCode": "AccessDenied",
"userIdentity": {
"arn": "arn:aws:sts::4471…:assumed-role/renewal-agent-pubsec/renewal-CIV-88-a41c"
},
"requestParameters": {
"bucketName": "acme-contracts-prod",
"key": "pubsec/OTHER-12/msa-2024.pdf"
}
}Read the arn and the key together and the event is self-describing: the run for CIV-88 tried to read OTHER-12's document and was refused. Nothing outside this record is needed to reach that conclusion. Under a shared service account the same event carries assumed-role/renewal-agent/i-0a3f…, the instance id, which is a fact about where the code ran rather than about what it was doing, and answering "was this read legitimate?" requires joining CloudTrail against application logs on a timestamp, hoping the application logged the customer id on that code path, and accepting a window of ambiguity wherever two runs overlapped. On a pod handling eight concurrent runs, timestamp correlation gives you eight candidate tasks per event.
| Shared service account | Per-request derived credential | |
|---|---|---|
| Principal in the log | renewal-agent + instance id | renewal-agent-pubsec + renewal-CIV-88-a41c |
| "Which task did this?" | Join app logs on timestamp | Read the session name |
| Ambiguity under concurrency | One event maps to N in-flight runs | One event maps to one run |
| Detecting a cross-tenant read | Requires an allowlist of expected keys per run, computed offline | Appears as an AccessDenied at the moment it happens |
| Alerting rule | Anomaly detection on volume or key patterns | errorCode = AccessDenied on any agent session |
| Blast radius of one compromised turn | Everything the role holds | Everything the ticket named |
The alerting row is the practical payoff. Under ambient authority there is no denial to alert on, because every injected read succeeds, so detection has to be statistical and therefore both late and noisy. Under derived credentials the injected read produces a hard failure with a principal attached, and a single CloudWatch filter on AccessDenied for any principal matching assumed-role/renewal-agent-* is a high-signal alert that fires within seconds. In practice you tune it for the handful of legitimate races — an object deleted between listing and reading — and those are rare enough to page on.
The eight-candidates figure is not a measurement either; it is what the record's contents force. Under a shared service account the principal in every event is the role plus the instance id, and both of those are constant across every run executing on that pod, so the field takes the same value for all of them and can separate none of them. What is left to correlate on is the timestamp, and concurrent runs are concurrent precisely in the sense that their timestamps overlap. With no discriminating field and no discriminating time, the candidate set is the whole in-flight set unreduced: eight runs, eight candidates, one event. The two numbers are equal because the record contributed nothing, which is the general result — an identifier that does not vary across the things you are trying to tell apart carries no information about which one acted. It also means the ambiguity scales with concurrency, so the investigation is hardest exactly when the system is busiest, which is when incidents tend to happen. The session name breaks the tie because it is derived from the task, so it takes a different value on every concurrent run by construction, and the candidate set is one at any load.
Tools that authenticate as themselves
Per-request credentials constrain what the agent's own identity can reach. They say nothing about tools that authenticate as themselves. A send_invoice tool holding a vendor API key, or an internal microservice that trusts anything inside the VPC, is unaffected by every mechanism in this chapter, because the agent never presents a credential to it — it presents an argument, and the tool acts with authority the agent never had to obtain. That is a different failure, it has a name, and it is the subject of the next chapter.
Two smaller boundaries are worth marking. Derived credentials do not constrain reasoning: an agent correctly scoped to CIV-88 can still be induced to summarise CIV-88's contracts badly, or into the wrong channel, and every leg of that stays inside its grant. And they do not survive a broker whose ticket verification can be skipped, which is why the ticket signature and the deny-by-default manifest are load-bearing rather than decorative — a broker that accepts an unsigned {"customer_id": …} from the agent process has reintroduced ambient authority with extra steps and a latency penalty.
The mechanism, throughout, is cheap and the property is fragile. An AssumeRole call with a session policy takes an afternoon to write; keeping the sentence "an attacker who fully controls this agent reaches these customers and no others" true through two years of deployments, tickets and new tools is the actual work, and it is done by identities that stay separate, manifests that make inheritance explicit, and a broker that sits where the agent cannot reach it.
Architectural mitigations — read the current version at https://daviddominguez.dev/learn/en/architectural-mitigations/request-scoped-credentials