The permission that looks fine until two people share it
Suppose you build an internal agent that opens and comments on issues in two private repositories, one for a payments team and one for a marketing team. You wire it to a single GitHub credential, call it the integration's service account, and that account happens to have read and write access to both repositories, because the same platform team maintains the bot. Ana works on payments. Bo works on marketing. Both talk to the same agent through the same chat interface.
Ana asks the agent, "what's the status of the checkout bug." Bo asks, "what's in the marketing backlog." Nothing about the plumbing stops Bo from asking, "show me open issues in the payments repo," and getting an answer, because the credential the agent actually uses to call GitHub's API has access to both. The agent's instructions might say something like "only show each user their own team's issues," but that's a request shaped by a prompt, not a boundary enforced by a system. The service account can access both repositories. That does not mean every caller talking through it should be able to.
This is the problem this article works through: choosing who owns an integration credential, and making sure the agent checks each caller's own authority before it acts, rather than borrowing the authority of whichever account happens to hold the keys. It's a design decision that shows up any time one agent process serves more than one human, or more than one downstream automation, through a shared connection to an external system.
Two different questions that get merged into one
There are two separate questions hiding inside "does this agent have access."
The first is: which account does the tool call authenticate as? Call this the credential owner. In the example above, it's a GitHub App or personal access token belonging to the integration, not to Ana or Bo individually.
The second is: who is actually asking, right now, in this request? Call this the caller. Ana and Bo are both callers of the same agent, using the same credential owner, but they should not have the same authority.
A minimal scaffold can conflate these: one process, one API key, one identity as far as the downstream system is concerned. The credential owner's permissions become the ceiling for every caller, and nothing narrows that ceiling per person unless you build it in deliberately.
LangChain's write-up on managed connections for deep agents describes exactly this split, distinguishing agent-owner credentials from per-caller identity, with separate connection types for secrets and OAuth flows (LangChain, "Connections: managed credentials and per-caller identity for managed deep agents," https://www.langchain.com/blog/connections-managed-credentials-and-per-caller-identity-for-managed-deep-agents, 2026-09-09). That distinction is the right shape, though the specific SDK mechanics for wiring it are vendor-specific and worth rechecking against current documentation before you build a tutorial around them, since managed-connection features change quickly.
The Model Context Protocol's authorization specification approaches the same problem from the transport side: tokens are scoped to a specific resource, not treated as a blanket credential usable anywhere the holder decides to send it (Model Context Protocol, "Authorization," https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization, version 2025-11-25). A resource-bound token answers "can this token be used against this server," which is a narrower question than "should this particular human be allowed to see this particular repository." You still need the second check even after the first one passes.
Building the identity table
The fix is not a smarter credential. It's a table that keeps five fields distinct on every request, so the system can refuse to conflate them even under a badly written prompt.
| Field | Ana's request | Bo's request |
|---|---|---|
| Caller ID | user:ana | user:bo |
| Authenticated via | session token, verified at request time | session token, verified at request time |
| Credential owner (tool call) | svc:github-integration | svc:github-integration |
| Allowed target repository | payments-repo | marketing-repo |
| Request ID | req-0091 | req-0092 |
The credential owner is identical for both requests. That's the point of a shared service account: one integration credential, reused for efficiency. What differs, and what has to be checked before any tool call executes, is the allowed target for that specific caller. The agent's job is to look up caller ID, find the caller's permitted resources, and reject any tool call whose target falls outside that set, regardless of what the service account itself is technically capable of reaching.
Here's a small, deliberately toy Python example that shows the mechanism without any real network call. It keeps everything in memory, which means it proves the logic works for the calls made inside this one program run. It does not prove anything about how a real GitHub App, OAuth provider or production authorization server behaves; that would require testing against the actual external system.
from dataclasses import dataclass
@dataclass
class Caller:
caller_id: str
allowed_repo: str
CALLERS = {
"ana": Caller(caller_id="ana", allowed_repo="payments-repo"),
"bo": Caller(caller_id="bo", allowed_repo="marketing-repo"),
}
def authorize_and_call(caller_id: str, target_repo: str):
# caller_id comes from verified request context, never prompt text.
caller = CALLERS.get(caller_id)
if caller is None:
return {"status": "authentication_required", "resource_read": False}
if target_repo != caller.allowed_repo:
return {"status": "denied", "resource_read": False,
"reason": f"{caller_id} not permitted on {target_repo}"}
# In a real system, this branch would use the shared service
# credential to make the actual API call. Here it is a stand-in.
return {"status": "ok", "authorized": True, "repo": target_repo}
print(authorize_and_call("bo", "payments-repo"))
print(authorize_and_call("ana", "payments-repo"))
print(authorize_and_call("unknown", "payments-repo"))Running this produces three results, all from this in-memory mock, not from any live repository:
{'status': 'denied', 'resource_read': False, 'reason': 'bo not permitted on payments-repo'}
{'status': 'ok', 'authorized': True, 'repo': 'payments-repo'}
{'status': 'authentication_required', 'resource_read': False}
Bo's request for the payments repository is refused before any tool call happens, even though the underlying svc:github-integration credential could technically read that repository. Ana's request reaches the authorized branch; the example does not read repository data. A caller ID that doesn't match anything in the table gets authentication_required, not a quiet default to some other caller's access. That third case matters more than it looks: a system that falls back to "just use the owner's permissions" when it can't identify the caller is worse than one that fails loudly, because a failure you can see is one you can fix.
What this looks like operationally, and what breaks it
The mock above only proves that the branching logic is correct for the three calls shown. Making this hold in a running system means answering three operational questions that the toy example glosses over.
First, what happens mid-session if the caller changes? Picture a UI where a session starts authenticated as Ana, and partway through, the browser tab gets handed to Bo without a fresh login. If the in-flight request is bound to a mutable global variable holding "current user," Bo inherits whatever state Ana's half-finished request left behind. The fix is to bind caller identity to the request object at the moment it's authenticated, not to a slot that gets overwritten. Transport connections may be pooled safely if identity and permission state are kept separate per request. Bo must not inherit Ana's delegated user credential. Merely handing someone an already logged-in browser tab does not tell the server that the human has changed; that requires logout or another authentication step.
Second, what happens when caller context is missing entirely, say a webhook fires an automation with no attached user? The safe default is the same as the "unknown" case above: refuse with an explicit error, read nothing, and do not fall back to running as the agent owner. A system that treats "I don't know who's asking" as "assume the owner's authority" has quietly turned every unauthenticated trigger into a privileged one.
Third, how do you audit this after the fact? An audit record needs the request identity and authorization context from the table above: which caller was authenticated, which credential actually made the call, and which resource was touched. A log line that only says "service account read payments-repo" cannot answer whether that read was legitimate, because it erased the one fact that would tell you.
The Python function above runs correctly for the inputs shown. It says nothing about how your actual OAuth provider, session store or GitHub App handles concurrent requests, token refresh, or a compromised session. Treat in-memory logic as proof of the branching rule, and treat production behavior as something you verify separately against the real system.
Give every tool call two fields, not one: which account is authenticating to the external system, and which human or process triggered this specific request. Never let a single field do both jobs.
Don't rely on the prompt to describe the boundary. The check has to run in code that executes regardless of what the model decided to write in its reasoning.
An unidentified caller gets authentication_required and no resource read, never a default to the owning account's broader access.

An existing boundary this extends
A companion piece on this site works through the related question of what an agent's permission configuration enforces once you have a single operator and a single credential: the difference between rules the harness actually checks and instructions the model merely tries to follow, plus why read access is often left wide open by default (see /articles/claude-code-permissions-are-your-org-chart). That article's frame, that a permission system is enforced by the harness rather than by the model's good intentions, is the same frame this article applies to a harder case: multiple distinct callers sharing one integration credential. The identity table above is the multi-caller extension of that single-operator lesson, not a replacement for it.
The two source specifications cited here point at the pieces you'd actually need to wire this into a production system: LangChain's managed-connection distinction between agent-owner and per-caller credentials, and MCP's resource-bound token model, which stops a single token from being read as blanket permission everywhere it's presented. Neither source is a finished implementation you can drop in; both describe a shape worth building toward, checked against current documentation before you commit to a specific SDK call.
What would prove this wrong
If a system genuinely needs every caller to have identical access to every resource the service account can reach, then authorization can use a shared role policy rather than individual resource rows. Authentication, attribution, revocation and per-caller limits can still require caller identity. The table above only earns its keep when different callers are supposed to see different things. Test this by asking: if I removed the caller-to-resource lookup entirely and let every authenticated user use the full service account, would anyone's access change? If the honest answer is no, you don't have the problem this article is solving, and adding the extra table is needless overhead. If the answer is yes, the lookup enforces an actual access distinction.
Check the decision
Once caller identity and credential ownership are separated, the next useful step is understanding what your harness actually enforces versus what it merely lets the model attempt.

