Pidoku

Identity and Authorization

Advanced 55 min Difficulty 4/5 Lesson 02 of 05

Prerequisites Agent Threats

The idea in one minute#

When an agent calls a tool, three identities are involved: the user who asked, the agent doing the work, and the resource being reached. A secure design keeps all three visible on every call. The agent does not use a shared service account, and it does not hold the user’s password or full token. Instead it presents a credential that says “agent X, acting for user Y, may do Z on resource R, for the next few minutes” — and the resource checks every part of that sentence. Getting there uses existing standards: OAuth 2.1, token exchange, workload identity and, for MCP, the protocol’s own authorization model.

A picture#

sequenceDiagram
  participant U as User
  participant IdP as Identity provider
  participant A as Agent runtime
  participant STS as Token service
  participant G as Tool gateway
  participant R as Resource (MCP server → business system)
  U->>IdP: sign in
  IdP-->>U: user token
  U->>A: "Close ticket 8841" + user token
  Note over A: the agent has its own workload identity
  A->>STS: exchange: subject = user token, actor = agent identity,<br/>audience = ticket server, scope = tickets:write:8841
  STS-->>A: delegated token (5 min, one audience, one scope)
  A->>G: tools/call close_ticket + delegated token
  G->>G: verify signature, audience, expiry, scope, policy
  G->>R: call as user Y via agent X
  R-->>G: result (audit: Y, via X)
  G-->>A: result

How it really works#

The three anti-patterns#

Anti-patternWhy it fails
A shared service account — the agent uses one powerful key for all usersThe agent can reach everything for everyone; a hijack from user A’s session exposes user B’s data; audit logs show only “the agent”
The user’s full credential handed to the agent — password, session cookie or broad OAuth tokenThe agent can do anything the user can, forever, and so can whoever hijacks it
Static API keys in tool configurationLong-lived, unscoped, copied into files and repositories, and impossible to attribute

What a good agent credential contains#

subject    the user on whose behalf the action is taken
actor      the agent (and its version) performing it
audience   the single resource server that may accept this token
scope      the narrowest permission that does the job — ideally naming the resource
expiry     minutes
binding    proof-of-possession, so a stolen token is useless without the agent's key

Each field defeats a specific abuse: audience stops a token for the calendar server being replayed to the payments server; scope limits a hijacked agent to the task at hand; expiry bounds a leak; actor makes the audit trail truthful; binding stops replay.

How it is built#

1. Agents have their own identity. Each agent workload gets a cryptographic identity from the platform — SPIFFE/SPIRE, or the cloud’s workload identity — rather than a shared secret. It identifies which software is calling, and can include the agent’s name and version.

2. Users delegate; agents do not impersonate. The user authenticates with the identity provider. The agent runtime then performs an OAuth token exchange (RFC 8693): it presents the user’s token as the subject and its own identity as the actor, and asks for a token for one audience and one scope. The result carries both identities.

3. Scope down at every hop. If the agent delegates to a sub-agent or a tool server calls another service, each hop exchanges its token for a narrower one. Authority only shrinks along the chain. A sub-agent that reads web pages gets no token at all.

4. The resource enforces. The business system applies the user’s permissions — the agent can never do more than its user — and may apply a further restriction for agent-mediated access: “agents may draft but not send”.

5. Tokens stay out of the context. The runtime attaches the token to the outgoing call in code. The model never sees it and cannot print it.

MCP authorization#

A remote MCP server is an OAuth-protected resource, and the 2026-07-28 specification tightened the model:

  • OAuth 2.1 with PKCE; the MCP server is a resource server and publishes metadata pointing to its authorization server.
  • Resource indicators: the client states which server a token is for, and servers must reject tokens issued for another audience — so one server cannot reuse your token elsewhere.
  • Issuer validation on authorization responses, closing a class of mix-up attacks.
  • Client ID Metadata Documents replace dynamic client registration: a client is identified by a URL it controls, instead of registering anonymously with every server.
  • No token passthrough: an MCP server must not forward the token it received to an upstream API; it obtains its own.
  • Enterprise-Managed Authorization, an official extension: instead of every user clicking through a consent screen per server, the organisation’s identity provider decides. The client exchanges the user’s identity for an ID-JAG (an identity-assertion grant) and presents it to the MCP server’s authorization server, which issues the access token. Access to each MCP server becomes a policy in the IdP — by group, role and device posture — and can be revoked centrally.

For local stdio servers there is no network authorization: the server runs with the privileges of the process that launched it, which is why local servers belong in a sandbox.

Agent to agent#

Across an A2A boundary:

  • Authenticate the peer. Verify the Agent Card’s signature against the publisher’s domain; use mutual TLS or signed requests.
  • Present delegated authority, scoped for that peer. Do not forward the user’s token.
  • Trust the identity, not the content. A correctly authenticated partner agent may itself have been hijacked. Its output goes through the same input rail and taint tracking as a web page.
  • Record the chain. The audit trail shows user → agent → partner agent, with the scope at each hop.

Authorization policy#

Authentication says who; policy says whether. Evaluate, on every tool call:

principal      user Y (groups, tenant)  via  agent X (version, risk tier)
action         tool and operation
resource       the specific object: ticket 8841, repo acme/api, mailbox folder "Invoices"
context        task ID; session taint; time; approval present?; budget remaining
decision       allow | deny | require approval

Expressing this in a policy engine — OPA with Rego, Cedar, or a library in your own code — keeps rules reviewable and testable. Three policies worth having from the start:

  • Deny by default. An agent has no tool until one is granted for a task type.
  • Read/write split. Read scopes are granted widely; write scopes are narrow and often gated.
  • Taint-aware. Outbound and irreversible actions are denied or gated once untrusted content is in the session.

An inventory of non-human identities#

Agents multiply identities: workloads, tokens, API keys, service accounts for tools. Keep a registry — every agent, its owner, purpose, the tools and scopes it may request, and when it was last reviewed. Unowned agents with standing access are how ASI10, the rogue agent, comes about. Review grants as you would for employees, and remove what is unused.

Code#

A token service that issues delegated, scoped, short-lived tokens, and a resource that checks every field. HMAC stands in for a real signature scheme.

Go
// delegate.go — issue and verify a delegated, audience-bound, scoped, short-lived token.
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"
)

type claims struct {
	Sub   string `json:"sub"`   // the user
	Act   string `json:"act"`   // the agent acting for them
	Aud   string `json:"aud"`   // the one server that may accept it
	Scope string `json:"scope"` // the narrowest permission
	Exp   int64  `json:"exp"`
}

var key = []byte("demo-signing-key") // a real service uses asymmetric keys from a KMS

func sign(c claims) string {
	body, _ := json.Marshal(c)
	mac := hmac.New(sha256.New, key)
	mac.Write(body)
	return base64.RawURLEncoding.EncodeToString(body) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

// exchange is the token service: the user's entitlements cap what any agent can be given.
func exchange(user, agent, audience, scope string, entitlements map[string][]string, now time.Time) (string, error) {
	for _, s := range entitlements[user] {
		if strings.HasPrefix(scope, s) {
			return sign(claims{user, agent, audience, scope, now.Add(5 * time.Minute).Unix()}), nil
		}
	}
	return "", fmt.Errorf("user %s is not entitled to %s", user, scope)
}

// verify is what the resource server does on every call.
func verify(token, self, needScope string, now time.Time) (claims, error) {
	var c claims
	parts := strings.Split(token, ".")
	if len(parts) != 2 {
		return c, errors.New("malformed token")
	}
	body, _ := base64.RawURLEncoding.DecodeString(parts[0])
	sig, _ := base64.RawURLEncoding.DecodeString(parts[1])
	mac := hmac.New(sha256.New, key)
	mac.Write(body)
	if !hmac.Equal(sig, mac.Sum(nil)) {
		return c, errors.New("bad signature")
	}
	if err := json.Unmarshal(body, &c); err != nil {
		return c, err
	}
	switch {
	case c.Aud != self:
		return c, fmt.Errorf("token is for %s, not %s", c.Aud, self)
	case now.Unix() > c.Exp:
		return c, errors.New("token expired")
	case c.Scope != needScope:
		return c, fmt.Errorf("scope %s does not permit %s", c.Scope, needScope)
	}
	return c, nil
}

func main() {
	now := time.Unix(1_790_000_000, 0)
	entitlements := map[string][]string{"alice": {"tickets:write:"}}

	tok, _ := exchange("alice", "support-agent@v12", "ticket-server", "tickets:write:8841", entitlements, now)
	try := func(label, self, scope string, at time.Time) {
		c, err := verify(tok, self, scope, at)
		if err != nil {
			fmt.Printf("%-34s DENIED  %v\n", label, err)
			return
		}
		fmt.Printf("%-34s allowed: %s via %s\n", label, c.Sub, c.Act)
	}
	try("close ticket 8841", "ticket-server", "tickets:write:8841", now)
	try("close a different ticket", "ticket-server", "tickets:write:9000", now)
	try("replay to the payments server", "payments-server", "tickets:write:8841", now)
	try("use it ten minutes later", "ticket-server", "tickets:write:8841", now.Add(10*time.Minute))

	_, err := exchange("alice", "support-agent@v12", "payments-server", "payments:refund:8841", entitlements, now)
	fmt.Printf("%-34s DENIED  %v\n", "ask for a scope alice lacks", err)
}

A hijacked agent holding this token can close ticket 8841 for the next five minutes. That is the whole blast radius.

Remember this#

  • Three identities on every call: user, agent, resource.
  • Never a shared service account; never the user’s full credential.
  • Token exchange yields a credential naming subject, actor, audience, scope and a short expiry.
  • Authority only shrinks along a delegation chain; tokens never enter the model’s context.
  • MCP servers are OAuth resource servers: audience-bound tokens, no passthrough, and enterprise-managed authorization through the IdP.
  • Keep a registry of agents and review their grants.

Try it#

  1. Run delegate.go. Change the expiry to one hour and argue for and against it.
  2. For an agent you know, write the credential it uses today in the six-field form. Which fields are missing or too broad?
  3. Write three authorization policies for a code-review agent in the principal / action / resource / context form.

Check yourself#

  1. What is wrong with an agent using one service account for all users?
  2. What does the audience field prevent?
  3. Why does Enterprise-Managed Authorization improve on per-server consent screens?

Sources#

↑↓ navigate↵ openesc close

drag to pan · scroll to zoom