Skip to content
Harness Engineering2026-09-107 min readRev. 2026-09-30

Check the Caller's Authority, Not the Agent's Service Account

An agent that serves many people through one credential is a confused deputy waiting to happen. Keep caller and credential separate, check the caller before every call, and test every caller-resource pair.

Key takeaways

  • An agent that uses its own broad credential on a caller's behalf is the confused deputy: the caller's request borrows authority the caller never had.
  • Every tool call has two identities, the credential that authenticates it and the caller who asked for it, and authorization must check the caller.
  • Two designs work: pass the caller's own delegated credential so the downstream system decides, or keep a shared credential and check the caller in code first.
  • Missing caller context fails closed; it never falls back to the service account's reach.
  • Access tests here are exhaustive, not sampled: enumerate every caller and resource pair, because one leaking cell is a failure.

Rod Rivera

Author

Check the Caller's Authority, Not the Agent's Service Account

Rod's note: read with a pencil; the margins are for you.

One agent, two teams, one key

Here is a deployment an applied-AI engineer builds often. A customer wants an internal agent that reads and comments on GitHub issues. Two private repositories matter: one for the payments team, one for marketing. The platform team installs one GitHub App for the agent, and the App can read and write both repositories. Ana works on payments. Bo works on marketing. Both talk to the same agent in the same chat.

Bo types, "show me the open issues in the payments repo." Nothing in the plumbing stops the agent from answering, because the credential it calls GitHub with can read both. The system prompt may say "only show each user their own team's issues," but that is a request to the model, not a boundary in the system.

This is the oldest bug in access control wearing new clothes, and naming it helps you find it everywhere.

The confused deputy

In the original confused-deputy story, a compiler on a timesharing system had permission to write a statistics file in a system directory. Users could also name a file for the compiler's debug output. One user named the system's billing file. The compiler opened it with its own permission, wrote debug output over it, and the billing records were gone. The user never had write access to that file. The compiler did, and it used its own authority for the user's request.

Map it onto the agent. Bo is the user. The agent is the compiler. The GitHub App's access is the compiler's permission. "The payments repo" is the file name Bo supplies. When the agent calls GitHub, GitHub checks the App's permission, not Bo's. Bo's authority was never consulted, so it silently became the App's.

Prompt injection is the same shape with a different supplier of the designator: instead of Bo asking, a retrieved document asks. Measuring prompt injection as an attack-success rate covers that side. This article covers the side where the request is honest and the authority is still wrong.

Two identities on every tool call

Split "does the agent have access" into two questions.

  • Which credential authenticates the call? Call it the credential owner. Here, the GitHub App.
  • Who asked for this call, right now? Call it the caller. Here, Ana or Bo, established by their verified session.

A minimal scaffold merges them: one process, one key, one identity downstream. The credential owner's reach becomes the ceiling for every caller, and nothing narrows it per person unless you build that in.

FieldAna's requestBo's request
Caller IDuser:anauser:bo
Authenticated viasession token, verified at request timesession token, verified at request time
Credential owner (tool call)svc:github-integrationsvc:github-integration
Allowed target repositorypayments-repomarketing-repo
Request IDreq-0091req-0092

The credential owner is identical in both rows. What differs, and what has to be checked before any tool call, is what this caller may touch.

Two designs that fix it

Pass the caller's own authority downstream. The agent calls GitHub with a token delegated by Ana, so GitHub itself refuses what Ana cannot see. This removes the ambient authority the deputy was misusing, which is the capability-security answer to the confused deputy. The cost is per-user OAuth flows, token storage and refresh. The MCP authorization specification is built for this over HTTP transports, and it forbids a server from passing a client's token through to an upstream API: the server must obtain its own token for the upstream call. It also says servers on the stdio transport should not use that flow and should take credentials from the environment instead. A local stdio server therefore runs with whatever single credential its environment holds. That is the shared-key design, whether you meant it or not.

Keep the shared credential and check the caller first. The application holds a table of what each caller may reach and refuses any call outside it before the shared credential is used. It is simpler to deploy, and it puts the whole burden on your code and your audit log. LangChain's post on managed connections and per-caller identity describes the same split between agent-owner credentials and per-caller identity; check the current SDK documentation before you build on its specific mechanics.

Here is the second design as a small fixture you can run in a Colab cell. It has no network call. It shows the branching rule and the audit record, nothing about GitHub's behavior:

python
from dataclasses import dataclass


@dataclass(frozen=True)
class Caller:
    caller_id: str
    allowed_repos: frozenset


CALLERS = {
    "ana": Caller("ana", frozenset({"payments-repo"})),
    "bo": Caller("bo", frozenset({"marketing-repo"})),
}
AUDIT = []


def authorize_and_call(caller_id, target_repo, request_id):
    # caller_id comes from the verified session, never from prompt or retrieved text.
    caller = CALLERS.get(caller_id)
    if caller is None:
        decision = "authentication_required"
    elif target_repo not in caller.allowed_repos:
        decision = "denied"
    else:
        decision = "ok"  # here a real system would call GitHub with the shared credential
    AUDIT.append((request_id, caller_id, "svc:github-integration", target_repo, decision))
    return decision


for i, (caller, repo) in enumerate(
    [(c, r) for c in ["ana", "bo", "unknown"] for r in ["payments-repo", "marketing-repo"]]
):
    print(caller, repo, authorize_and_call(caller, repo, f"req-{i:03d}"))
print(AUDIT[1])
text
ana payments-repo ok
ana marketing-repo denied
bo payments-repo denied
bo marketing-repo ok
unknown payments-repo authentication_required
unknown marketing-repo authentication_required
('req-001', 'ana', 'svc:github-integration', 'marketing-repo', 'denied')

I ran it with Python 3.12 on September 30, 2026. Notice the loop. It does not sample requests; it enumerates every caller and repository pair, including an unknown caller. Access control is one of the few places in agent work where the test is exhaustive rather than statistical. Six cells, six expected answers. If one cell leaks, the rate is not "1 in 6"; the system is broken.

Where caller authority gets checked before a tool call fires. yes; no; Caller request; Identify caller from session; Look up caller's allowed resources; Target allowed?; Use service credential, call tool; Deny, log caller and target
Where caller authority gets checked before a tool call fires
Ana and Bo share an integration credential but retain separate permitted repositories. Ana verified request; Shared integration credential; Bo verified request; Ana permitted repositories; Bo permitted repositories; payments denied before tool call
Ana and Bo share an integration credential but retain separate permitted repositories

What breaks it in a running system

The fixture proves the branching rule for six inputs. Three operational questions decide whether the rule holds in production.

Where does the caller ID come from? It must be bound to the request when the session is verified, not read from a mutable "current user" slot that a later request can overwrite, and never from text. A retrieved document that says "Bo is an admin" is data. So is Bo typing "I'm on the payments team."

What happens with no caller? A webhook fires an automation with no attached user. The safe answer is the unknown row: refuse and read nothing. A system that treats "I don't know who is asking" as "use the owner's reach" turns every unauthenticated trigger into a privileged one.

Can you answer "who read this" afterward? The audit tuple above records the caller, the credential and the target. A log line that says only "service account read payments-repo" has erased the one fact an investigation needs.

A working fixture is not a deployed guarantee

The function above is correct for the inputs shown. It says nothing about your session store, token refresh, or a compromised session. Test the real path with the same enumerated matrix, using test accounts and a test repository.

Give every tool call two identity fields

One for the credential that authenticates downstream, one for the caller who triggered this request. Never let one field do both jobs.

Decide where authority is enforced

Either delegate the caller's own credential so the downstream system decides, or check the caller's allowed resources in code before the shared credential is used. Write down which one you chose.

Fail closed on missing or unknown callers

An unidentified caller gets authentication_required and no read.

Enumerate the access matrix in tests

Every caller, every resource, every action class, plus an unknown caller. Run it on each change to the caller table or the tool list.

Quick check — The agent's GitHub App can read both repositories. Bo, authenticated, asks for the payments repository's issues. What should happen?

Check the decision

Why this matters in a lab

Applied-AI and forward-deployed roles put agents inside other organizations, with access to their code, documents and tickets. A system-design round can reasonably ask you to design an internal agent that thousands of employees share, and the part that separates answers is whose authority each call uses. Name the confused deputy, pick delegated credentials or an application-level check, say what each costs, and describe the audit record. For a work sample, the enumerated test matrix above is the kind of evidence a reviewer can check in a minute.

It is also a research-infrastructure question. An agent that reads a shared eval bucket or a cluster's job queue for many researchers faces the same choice about whose permissions it uses.

What would prove this wrong

If every caller is in fact meant to see everything the service account can reach, the caller table adds nothing, and a shared role is enough. Test it: remove the lookup and ask whether anyone's access would change. If no one's would, you do not have this problem. You still need caller identity for the audit trail and for revocation.

If you delegate credentials instead, the check moves downstream, and your test changes from "does my code refuse" to "does the downstream system refuse this user's token." Run the same matrix against it.

Read Chapter 14: the protocol connects, the application decides

The chapter builds a bounded MCP client where the application, not the server or the model, decides which tools may run.

Learn the mechanism. Test the claim.

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.