Skip to content

15 min read

A delegated credential is bounded by tool, resource and clock

On this page

Delegated authority is one party acting on behalf of another, using authority that belongs to the second party: a support assistant issues refunds that a human support agent was entitled to issue, and the permission being spent belonged to the company rather than to the software. Systems have always done a version of this, since a nightly invoicing job also runs on somebody's authority, and an agent changes the terms because it chooses its own next step from text it has just read, so any sentence that reaches it is a candidate instruction. Agent authentication and delegated authority, the subject of this topic, is the problem of arranging that arrangement safely: which credential the software carries, what that credential is allowed to reach, how the human behind it travels alongside it, and what record is left when it is spent. The obvious way to build it is to hand the software the same login the person uses, which makes every instruction that reaches the agent exactly as powerful as the person is. The alternative this topic is about is a delegated credential, the token an agent presents when it acts for someone, built so that it is smaller than that someone: fewer operations, fewer objects, one service, less time.

This chapter is about how that credential is constructed and where each of its bounds comes from, and one morning on a subscription billing product is the specimen it works from. At 03:12 the on-call engineer for that product was paged by an alert on refund.created. In ninety seconds the support assistant had issued thirty-seven refunds totalling $8,412, spread across nineteen customer accounts, none of which had an open ticket. The trigger was a support email whose signature block contained instructions addressed to the assistant, and the assistant had followed them. That is a prompt injection, meaning instructions smuggled into content the agent was reading as data, and it is a real problem with its own chapter elsewhere; the number that mattered in the postmortem was nineteen, because the customer who sent the email was a customer of one account and the assistant had reached eighteen others.

You meet this wherever an assistant does something rather than describes something — a support assistant that issues refunds, a coding agent that opens pull requests, a scheduling assistant that answers invitations, a tool server you attach to an account through a "connect your calendar" screen. Some credential is sitting in that process, and the two questions worth asking about it are whose authority it carries and how much of that authority it carries. The billing team's answer to the second question was all of it, which is how one customer's email reached nineteen accounts.

Four bounds, and the fact that they multiply

Four bounds do the shrinking, and they are worth naming separately because they come from different places and fail in different ways.

The audience names the service the token may be presented to. A token minted for the billing API is rejected by the search API, the mail API and the internal admin console, so a copy that leaks out of one service's logs cannot be walked sideways into another. The scope names the operations, drawn from the tools the current task can actually reach: an assistant that answers "where is my invoice" needs invoices:read and nothing else, even though the same assistant, on a different task, may legitimately need refunds:write. The resource bound names the objects, which is the bound teams most often skip because it is the one that requires knowing something about the task rather than about the code — invoices:read across the whole tenancy is a very different credential from invoices:read on invoice inv_4419. The lifetime is the clock: five minutes rather than the length of the user's session, so a token found in a trace store in September is not a working key.

These are not four alternatives. To see why, count what a credential reaches as a set of possibilities rather than as a permission level. A holder of the credential can pick a service, an operation, an object and a moment, and any combination of those four that the credential permits is something they can do. The size of that set is therefore the product of the four counts, and each bound divides one factor. Halving the operations and halving the objects quarters the set; leaving the objects unbounded means the object factor is the whole tenancy no matter how small the other three are. They intersect, and the intersection is what makes the credential small:

json
{
  "iss": "https://auth.internal.example.com",
  "sub": "agent:support-assistant@v2026-03-04",
  "aud": "https://billing.internal.example.com",
  "scope": "refunds:write invoices:read",
  "resource": ["acct_88213", "inv_4419"],
  "iat": 1772596320,
  "exp": 1772596620,
  "jti": "tok_01HQ8ZK3M4"
}

Read against the incident, that token would have permitted one refund on one invoice for one account, and would have stopped working at 03:17 whether or not anyone noticed. Nineteen accounts becomes one. The email would still have been injected, the assistant would still have been fooled, and the loss would have been a single refund on the account of the person who sent the email.

Where each bound comes from

The audience comes from the deployment topology and is the easiest to derive, because it is a property of which service the tool talks to. If the tool manifest for create_refund says it calls billing.internal.example.com, that host is the audience, and there is no judgement call to make. The scope comes from the tool manifest as well: each tool declares the permissions it requires, and the credential for a run carries the union of the permissions of the tools that run may call, which is smaller than the union over all tools the agent has ever been given.

The resource bound is where the work is, because the resources are known only once the task is understood. In practice the pattern that holds up is to mint at the point where a human-supplied fact enters the system. A support agent working ticket TCK-9912 is working on the account attached to that ticket, and the account is on the ticket, not in the model's output, so the mint call takes the account from the ticket record and the model never gets a vote. The reasoning behind that rule is short and it is the whole of the resource bound's value. A bound is only a bound if the thing being bounded cannot choose it. A model that supplies the account id in a tool argument is choosing the limit that is supposed to constrain it, which leaves a field in a token that looks like a control and constrains nothing.

When the resource genuinely cannot be known in advance — an assistant that searches before it acts — the shape that works is two credentials: a broad-scope, read-only one for the search phase, and a narrow, resource-bound one minted after the target is chosen and confirmed. That two-phase pattern is worth spelling out because it is the answer to the objection that resource bounds are impossible for open-ended assistants. The search phase runs with invoices:read across the tenancy, which is broad, and it is broad over an operation that cannot change anything; the write phase runs with refunds:write on exactly the invoice the search returned and the user confirmed. An attacker who takes over the first phase gets a read of data the user could already see. An attacker who takes over the second phase gets one refund on one invoice. The dangerous shape is the single credential that carries both phases' permissions for the whole run, which is what you get by taking the union of every tool the agent owns and minting once at process start — that credential is broad in scope and unbounded in resource at the same time, and the two phases were the only thing keeping those two properties apart.

The lifetime comes from the task, and the sane default is the p99 duration of a run — the length that 99 runs in 100 finish inside — plus a small margin. For an interactive assistant that is 60 to 300 seconds. That derivation is the one people invert: a lifetime chosen from the task means a run that overruns has to get a new token, while a lifetime chosen from the session means the token outlives the reason it was minted. For the nightly reconcilers of chapter six, where an agent works through a list of users with nobody awake, it is the duration of one user's slice of the run, not the duration of the whole job. Chapter 6.12 covered how an MCP client obtains a token in the first place; the question here is what the authorisation server should put inside it, and the answer is that every one of these four fields should be filled by something other than a default.

ts
type MintRequest = {
  agent: string;          // stable agent identity + version
  audience: string;       // exactly one service
  scopes: string[];       // union over the tools this run may call
  resources: string[];    // ids taken from trusted records, never from model output
  ttlSeconds: number;     // p99 run duration + margin
};

async function mintForRun(task: SupportTask): Promise<string> {
  const tools = toolsAvailableFor(task.kind);          // e.g. ["lookup_invoice", "create_refund"]
  return tokenService.mint({
    agent: "support-assistant@v2026-03-04",
    audience: "https://billing.internal.example.com",
    scopes: dedupe(tools.flatMap((t) => t.requiredScopes)),
    resources: [task.accountId, task.invoiceId],       // from the ticket record
    ttlSeconds: 300,
  });
}

The line worth staring at is resources. It reads task.accountId, a field written by the ticketing system when a human opened the ticket. If it read an account id that the model produced in a tool argument, the resource bound would be decorative, because the thing being bounded would be choosing its own bound.

What each bound stops, and what it does not

Each bound is answering a different question, and each leaves a different hole. Laying them out together is the fastest way to see that no single one of them is the control.

BoundAnswersA leaked token still permitsTypical failure
Audience (aud)Which service may accept this?Everything in scope, on that one serviceMinting one token accepted by every internal API
ScopeWhich operations?Any object the operation can nameCopying the role that made the demo work
ResourceWhich objects?The named objects onlyTaking the id from model output rather than a record
Lifetime (exp)For how long?Everything above, until expiryMatching the session's lifetime instead of the task's

Read that table by its third column rather than its first, because the third column is where each bound's residual sits. Every row licenses something exact and leaves the other three factors untouched: audience binding licenses the claim that no other service will honour the token and leaves the whole of that service's data reachable; a scope licenses the claim that only these verbs work and leaves every object those verbs can name; a resource bound licenses the claim that only these objects are reachable and says nothing about how long. The multiplication matters more than any row: the residuals are what multiply, which is why the fourth row on its own is the weakest of the four despite being the one that usually gets built.

That last point is worth naming as a belief rather than as an oversight, because it is the shape of most partial implementations here. Teams that fix one bound and stop tend to fix the lifetime, because expiry is the easiest to implement and the easiest to point at in a review, and expiry is genuinely a real control. What holds still while it is being fixed is the reach. A token with a five-minute life and no resource bound still permits a full-tenancy scrape within those five minutes, and an agent's tool loop can issue several hundred requests in that window, so the number of objects an attacker touches is set by request throughput rather than by the clock. The clock is the quantity on the dashboard and the reach is the dangerous property, and the two move independently. A token bound to one invoice but valid for thirty days is the same mistake mirrored: a durable key sitting in whatever log captured it.

The over-scoped key is tempting for reasons that are worth respecting rather than dismissing, because a review that treats it as carelessness will not prevent the next one. During a prototype nobody knows which permissions the agent will need, since the tool set changes hourly and every narrowing produces a 403 that costs twenty minutes to diagnose from inside a model's error handling. The admin role removes an entire category of failure at a moment when the goal is to find out whether the thing works at all. What is missing is not discipline; it is a step in the path to production where the credential is derived from the tool manifest rather than chosen by a person, so that narrowing happens as a consequence of shipping rather than as a task someone has to remember.

The number the bounds are actually moving

It helps to put a figure on this, because "least privilege" — the rule that a component should hold only the permissions its current job requires — has been repeated into meaninglessness as a phrase. Take the billing API as it existed at the time of the incident: 41 endpoints, roughly 2,400 accounts, and an average of 26 invoices per account.

Count what the billing.admin service key reached the way the four bounds suggest. Any of the endpoints could be aimed at any of the accounts, so the reachable operation-and-object combinations are the endpoint count multiplied by the account count — something on the order of 41 × 2,400 before you even count individual invoices, and each account carrying twenty-six invoices on average means the true figure is larger again. The time factor is worse than any of them, because the key had no expiry, so it did all of that continuously.

Now count the per-run token the same way. It authorises 2 operations on 2 objects for 300 seconds, which is a product of two small numbers bounded by a short clock. Dividing the first product by the second gives a five-figure ratio before the clock enters the comparison at all, and the clock turns "for as long as the key exists" into five minutes. Comparing the two as "the set of things an attacker who steals this can do", the reduction is around five orders of magnitude, and none of it depends on detecting the attack. That last clause is the property worth keeping: the bounds hold whether or not anybody noticed the email, whether or not the model was fooled, and whether or not an alert fired.

That figure is also the honest argument for doing the work, because scoping is not free. Each tool now needs a declared permission set, the mint path needs a trustworthy source for resource ids, and the token service becomes a dependency on the request path — one that, if it is down, stops every agent run rather than degrading them. Teams routinely accept a p50 of 12 ms and a p99 of 40 ms for a local mint call, and the ones that do not usually discover they were caching a token far longer than its intended life, which is the lifetime bound being quietly removed by an optimisation.

Three things a scoped credential does not do

Being clear about these prevents the common overclaim that scoping "solves" agent security, and each one is a whole later chapter rather than a caveat.

It does not authenticate the human. The token above has sub: agent:support-assistant, which is the agent, and nowhere in it is the person the agent is acting for. A downstream service reading it knows a legitimate agent is calling with permission to refund invoice inv_4419, and it does not know whether the person who asked was the account's billing owner or someone who guessed a ticket number. That gap is the subject of chapter three.

It does not stop the agent doing a permitted wrong thing. If the task legitimately includes refunds:write on inv_4419 and an injected instruction persuades the assistant to refund inv_4419 when it should not have, the credential permits it, because the credential encodes what may be done rather than what should be done in this instance. The controls for that are per-action policy evaluation, which chapter five covers, and human approval, which topic 10.7 already covered — they operate on the decision rather than on the key.

It is not a sandbox. A scoped credential constrains what the agent can reach through the network calls it makes with that credential. It says nothing about the filesystem the tool runs on, the other environment variables in that process, or the second credential someone added last month for the metrics exporter. An agent process holding one perfectly scoped billing token and one unbounded database URL has the security posture of the database URL, since the bounds on the first credential place no constraint at all on what is done with the second.

A delegated credential, then, is a claim about how much of somebody's authority is in the room, and the four fields are the four ways of making that amount smaller. None of them is about detecting an attack, which is what makes them worth the trouble: they hold on the run where the prompt injection worked and the model was completely convinced.

The awkward part of everything above is that it assumes a mint step exists. The most common production integration has no such step, because the agent is running behind a web app that already has a perfectly good credential lying around — the browser's session cookie — and passing it through takes one line. That integration works, and its failure mode is the subject of the next chapter.

Agent identity and delegated authority — read the current version at https://daviddominguez.dev/learn/en/agent-identity-and-delegated-authority/a-delegated-credential-is-bounded-by-tool-resource-and-clock

Back to top