Securitysecurityauthorizationauthenticationleast privilegedelegated credentials

Permissions, Authentication and Authorisation

An agent should act as the user, with the user’s scoped credentials, and every tool must check authorisation in code; the prompt is not an access control list.

Interview question
Progress

Whose identity is the agent using?

When a tool runs, some credential authorises it. There are only two sane answers to “whose?”: the user’s (delegated) or a narrowly scoped service identity created for this agent and this task. The common wrong answer is a broad service account shared by every session, which means every user of the agent implicitly has every other user’s permissions, one injection away.

Model the agent as a client acting on behalf of a principal, exactly like a mobile app calling your API. The user authenticates once; the agent receives a short-lived token that carries the user’s identity and a scope no wider than the task. The tool backend sees the user, not the agent, and applies the same authorisation it would for any client.

Delegated and scoped credentials

OAuth-style delegation is the right mental model even if you do not use OAuth. The properties that matter: the token identifies the user, it expires, it is scoped to specific operations, and it can be revoked without rotating a shared secret. The MCP Lifecycle: Init, Discovery, Invocation lesson covers how MCP servers receive such tokens.

Scope narrowly and per task. A “summarise my inbox” task needs mail.read. A “reply to this thread” task needs mail.read and mail.send:thread=123. The token for the first task should not be able to send. If your identity provider cannot express that granularity, enforce it in the tool layer using the task context.

  • Short-lived: minutes to an hour, matching the session.
  • Scoped: operation and resource, not “full access”.
  • Attributable: audit logs show the user and the agent session, not a generic bot.
  • Revocable: killing the session invalidates the token.
  • Never in context: the model gets a tool, not the token (Secrets and Untrusted Output).

Authorisation checks belong in the tool, not the prompt

“Only show the user their own orders” is an authorisation rule. If it is implemented as a sentence in the system prompt, the check runs inside a probabilistic model that can be argued with. If it is implemented as a WHERE customer_id = :user clause in the tool, it cannot. Every tool that touches per-user data must resolve the acting user from the session and enforce ownership itself.

This also fixes a subtle bug: the model does not know the user’s id unless you tell it, and if you tell it, an attacker can tell it a different one. Bind identity to the session server-side and never accept it as a tool argument.

Identity comes from the session, never from model-generated arguments; authorisation is a code path.
1from dataclasses import dataclass
2
3@dataclass
4class Session:
5 user_id: str
6 scopes: set[str]
7
8def require(session: Session, scope: str) -> None:
9 if scope not in session.scopes:
10 raise PermissionError(f"session lacks scope {scope}")
11
12# Tool signature as seen by the model: get_order(order_id) — no user_id parameter.
13def get_order(order_id: str, *, session: Session) -> dict:
14 require(session, "orders.read")
15 row = db.one("SELECT * FROM orders WHERE id = %s AND customer_id = %s",
16 (order_id, session.user_id))
17 if row is None:
18 raise LookupError("order not found") # same error for missing and forbidden
19 return row
20
21def cancel_order(order_id: str, *, session: Session) -> dict:
22 require(session, "orders.cancel")
23 order = get_order(order_id, session=session) # ownership re-checked
24 return orders.cancel(order["id"], actor=session.user_id)

Least privilege as a design habit

Least privilege for agents means three things at once: the smallest set of tools for the task, the smallest scope on each credential, and the smallest data returned by each call. A get_customer tool that returns the full record including payment method exposes more than a get_customer_shipping_address tool, even if the model only ever needed the address. Return what the task needs; the rest cannot leak.

Review permissions with the same rigour as production IAM. The question “could this agent, if fully compromised, do X?” should have a short, written answer for every X you care about.

Key points

  • Agents act on behalf of a principal; use the user’s delegated, short-lived, scoped token or a per-task service identity.
  • A shared broad service account turns every injection into a cross-tenant breach.
  • Authorisation lives in tool code (ownership checks, scope checks), never in prompt wording.
  • Identity is bound to the session server-side; never accept user_id as a model-generated argument.
  • Least privilege covers tools, credential scope, and the data each call returns.

When to use — and when not to

Use it when
  • Any agent that accesses per-user or per-tenant data.
  • Agents that integrate with third-party systems via OAuth or API keys.
  • MCP deployments where servers are shared across users (MCP Overview).
Avoid it when
  • A single-user local tool on the developer’s own machine can use the developer’s own credentials directly.
  • Do not build a custom identity layer if your platform already issues scoped tokens; integrate with it.
  • Do not over-scope “for now” in a demo; demos become production.

Failure modes

  • The agent runs with a service account that can read every tenant; one injection dumps another customer’s data.
  • The model is told the user id in the prompt and an attacker overrides it in conversation.
  • A token scoped for reading is reused for a send tool because the same session object is passed around.
  • Audit logs attribute all actions to “agent-bot”, making incident response impossible.
  • Tools return full records and the model echoes fields the user was never authorised to see.