·2 min read·← All posts
Go OAuth RFC 8693 Agents Security

The scenario

Nurse Alice’s medical assistant agent needs to query patient records via an MCP server. The downstream API needs to know two things for the audit log:

A single-identity token gives you one or the other. RFC 8693 gives you both.

The token shape

{
  "sub": "alice@hospital.example",
  "roles": ["nurse", "shift_lead"],
  "iat": 1716700000,
  "exp": 1716700900,
  "aud": ["mcp://patient-records-server"],
  "act": {
    "sub": "medical_assistant_agent",
    "iss": "genie-api"
  }
}

sub is Alice (unchanged through the chain). act.sub is the agent. act.iss is the service that minted the actor identity.

N-hop chains

When the MCP server itself exchanges for an upstream EMR API call, the actors nest:

{
  "sub": "alice@hospital.example",
  "aud": ["https://api.upstream-emr/records"],
  "act": {
    "sub": "patient_records_mcp_server",
    "act": {
      "sub": "medical_assistant_agent",
      "iss": "genie-api"
    }
  }
}

Walking outward: alice → medical_assistant_agent → patient_records_mcp_server. The full chain in one signed JWT.

Genie’s implementation

// pkg/auth/tokenexchange/exchange.go
exchanged, claims, err := svc.Exchange(ctx, tokenexchange.Request{
    SubjectToken: nurseToken,                   // alice's JWT
    ActorID:      "medical_assistant_agent",
    Audience:     "mcp://patient-records-server",
})
// claims.Subject       = alice@hospital.example  (unchanged)
// claims.Actor.Subject = medical_assistant_agent
// claims.Actor.Issuer  = genie-api

The Service caches on (user_subject, actor_id, audience). The cache TTL is min(token_exp, subject_exp) − safety_margin. An exchanged token never outlives its underlying user session.

Why a loose verifier

A first-hop exchanged token has aud = mcp://patient-records-server. When the MCP server exchanges again, the verifier on its side has to accept that input — but the input’s audience won’t match the verifier’s default audience.

Solution: verify with audience-skip. Signature, expiry, issuer — all checked. Audience skipped because the audience of the input is meaningful to the previous hop, not to us. The audience of the output is set fresh and is what the next downstream will strictly verify.

What this enables

The audit row at the EMR API:

[2026-05-26 14:23:01] subject=alice@hospital.example
  action=read_patient
  patient_id=12345
  via=patient_records_mcp_server > medical_assistant_agent
  trace_id=...

One row. Full attribution. The regulator can answer “did Alice look at patient 12345” (yes), “did Alice click a button or did an agent do it on her behalf” (the agent), “who is responsible if the read was inappropriate” (Alice + the agent’s owning team).

That’s FREE-AI Rec 22 by construction. The full reference for Genie’s implementation is at packages/oauth-token-exchange.md.

← Back to all posts