Guide: adding AAuth auth to an MCP server (or any HTTP API)
This is a practical, implement-this-in-order guide for putting AAuth in front of
an MCP server or any HTTP API, so AAuth-identified agents can authenticate
and (optionally) carry a real user identity — without OAuth Dynamic Client
Registration, bearer tokens, or per-server accounts. It complements the deeper
research/05-connecting-resources-mcp.md.
Why bother. MCP’s OAuth path gives each agent a different
client_idper server, bearer tokens (stealable), and no portable identity. AAuth fixes exactly that: the agent has one self-sovereign identity (aauth:local@domain) it proves by signing each request; you verify against the Agent Provider’s published keys. No pre-registration, no shared secrets, per-request proof of possession.
0. Decide how far up the ladder you need to go
Each rung is complete and useful on its own. Most MCP servers start at rung 1.
| Rung | What you get | You need |
|---|---|---|
| 1. Identity-based | replace API keys: verify which agent is calling; ACL/rate-limit by agent id | signature verification only |
| 2. Resource-managed (two-party) | keep your existing OAuth/login; wrap your existing token, bound to the agent’s key | rung 1 + an AAuth-Access wrapper |
| 3. PS-asserted (three-party) | real end-user identity (sub, email, tenant, groups, roles) from the agent’s Person Server; you apply policy |
rung 1 + issue resource tokens + verify auth tokens |
| 4. Federated (four-party) | an Access Server enforces your policy and issues auth tokens | rung 3 + an AS |
Pick the lowest rung that meets your need. This guide covers rungs 1–3 (the AS in
rung 4 is a separate policy component; the resource-side wire is the same as
rung 3 with aud = your AS instead of the agent’s PS).
flowchart LR
R1["Rung 1<br/>Identity-based<br/><small>verify signatures,<br/>ACL by agent id</small>"] --> R2["Rung 2<br/>Resource-managed<br/><small>wrap your OAuth<br/>via AAuth-Access</small>"]
R2 --> R3["Rung 3<br/>PS-asserted<br/><small>end-user identity<br/>from the agent's PS</small>"]
R3 --> R4["Rung 4<br/>Federated<br/><small>an Access Server<br/>enforces your policy</small>"]
R1 -. "each rung is complete on its own;<br/>add the next only when you need it" .-> R4
1. The core competency: verify a signed request (rung 1)
This is the only thing you must build, and it’s the 80% case. On a request
carrying Signature-Key: sig=jwt;jwt="<agent token>":
flowchart TD
REQ["signed MCP request<br/>Signature-Key: sig=jwt;jwt=..."] --> HDRS{"3 sig headers present<br/>+ required components<br/>+ created in window?"}
HDRS -->|no| E401["401 + Signature-Error"]
HDRS -->|yes| TYP{"typ = aa-agent+jwt,<br/>alg = Ed25519?"}
TYP -->|no| E401
TYP -->|yes| JWKS["discover issuer JWKS:<br/>{iss}/.well-known/aauth-agent.json<br/>(issuer==iss, egress-admitted, cached)"]
JWKS --> VJWT{"JWT signature +<br/>exp/iat valid?"}
VJWT -->|no| E401
VJWT -->|yes| VSIG{"HTTP signature verifies<br/>with cnf.jwk?"}
VSIG -->|no| E401
VSIG -->|yes| PRIN["principal = sub<br/>(stable agent id)"]
PRIN --> POL{"your ACL / rate-limit /<br/>scope policy on sub?"}
POL -->|deny| E403["403 (plain, no Signature-Error)"]
POL -->|allow| OK["handle the MCP call"]
- Parse
Signature-Input,Signature,Signature-Key(correlate by label). - Check covered components include
@method,@authority,@path,signature-key, and thatcreatedis within your window (default 60 s). If a required component is missing →401+Signature-Error: error=invalid_input; required_input=(...). - Verify the agent token (a JWT):
- header
typ == "aa-agent+jwt",alg == "Ed25519"— the fully-specified identifier; rejectnone, the polymorphicEdDSA, and anything else (sig-key §3.3). Respond401 unsupported_algorithmwithAccept-Signature-Alg: Ed25519for a badalg; dwk == "aauth-agent.json"; fetch{iss}/.well-known/aauth-agent.json, confirm the document’sissuerequalsiss(host-poisoning defense), readjwks_uri, fetch the JWKS, find the key by the JWT headerkid, verify the JWT signature. Cache the JWKS (refresh on unknown kid, ≥1/min floor, ≤24h). Apply egress admission (HTTPS only, no cross-host redirects, no private/loopback IPs, size/time caps) — these URLs come from the token.expin the future,iatnot in the future.
- header
- Verify the HTTP signature with
cnf.jwkfrom the token. - Your principal is the
sub(e.g.aauth:k7q3p9n2@sandbox.agentprovider.dev) — stable across the agent’s key rotations. Key your ACLs, per-agent rate limits, and audit off it, exactly like an API-key id.isstells you which AP vouches for it — trust APs per your policy. - Note
ps(where you’d send a resource token for rung 3) andparent_agent(marks a sub-agent — attribute to the parent chain in audit).
Challenge unauthenticated callers with 401 +
AAuth-Requirement: requirement=agent-token (optionally alongside your legacy
WWW-Authenticate for non-AAuth clients). A policy denial after a valid
signature is a plain 403 (no Signature-Error).
Optional hardening for state-changing endpoints: require content-digest in the
covered components (advertise it in metadata, below), and/or keep a short replay
cache keyed by (key thumbprint, created, @method, @authority, @path).
aauth-corein this repo implements the whole verification path (sig::parse_request_signature+tokens::validate_agent_token+jwks_cache); reuse it or port it. It’s Ed25519-only and dependency-light.
Checklist — rung 1: ☐ parse the three headers ☐ enforce required components +
created window ☐ verify agent token via issuer JWKS (with egress admission +
caching + issuer match) ☐ verify HTTP sig with cnf.jwk ☐ principal = sub.
2. For MCP servers specifically
MCP over Streamable HTTP is just HTTP, so AAuth is middleware in front of your MCP endpoint:
- Where it goes. Run the rung-1 verification as a filter on the HTTP requests
carrying MCP JSON-RPC. The verified
subbecomes the MCP session’s principal. Per-agent tool ACLs and rate limits key off it. - Discovery. Advertise your MCP endpoint as an R3 vocabulary in your resource
metadata so agents that know only your hostname can find it:
{ "issuer": "https://mcp.example", "access_mode": "agent-token", "r3_vocabularies": { "urn:aauth:vocabulary:mcp": "https://mcp.example/mcp" } } - Scopes ↔ tools. For coarse control, define scopes like
tools.read/tools.execinscope_descriptionsand gate tool calls on the granted scope. For per-tool grants, R3’s MCP vocabulary expresses operations as MCP tool names so auth tokens can carry exactly which tools are authorized (R3 is exploratory — treat as directional). - Human-in-the-loop / elicitation. When a tool needs user consent or input,
don’t block — return
202+requirement=interactionand let the agent bring the user in (§4), then resume. - Which rung. Identity-based (rung 1) works today: verify the agent token, allowlist agents, done. Use rung 2 to wrap an existing OAuth-protected MCP server. Use rung 3 to get real end-user identity at the MCP server without running your own IdP.
Checklist — MCP: ☐ verification middleware in front of the MCP transport ☐
principal = agent sub ☐ advertise the MCP R3 vocabulary + access_mode ☐ map
tool consent to 202 interaction ☐ choose a rung.
3. Publish resource metadata
GET /.well-known/aauth-resource.json:
{
"issuer": "https://mcp.example",
"jwks_uri": "https://mcp.example/.well-known/jwks.json",
"access_mode": "agent-token",
"name": "Example MCP Server",
"scope_descriptions": { "tools.read": "List and read tools", "tools.exec": "Invoke tools" },
"signature_window": 60,
"additional_signature_components": ["content-digest"],
"authorization_endpoint": "https://mcp.example/authorize",
"r3_vocabularies": { "urn:aauth:vocabulary:mcp": "https://mcp.example/mcp" }
}
issuerMUST equal the origin the document is served from.jwks_uriis required only once you issue resource tokens / make signed calls / emit event tokens (rungs 3–4 and Events). A pure rung-1 verifier can omit it — it publishes no keys.access_mode(agent-token|aauth-access-token|auth-token) is advisory; the runtimeAAuth-Requirementyou return always wins and can differ per endpoint.
4. Rung 2 — wrap your existing auth (AAuth-Access)
Keep your current consent/login. When an endpoint needs it:
- Return
202with a deferred/interaction response:HTTP/1.1 202 Accepted Location: https://mcp.example/pending/abc123 Retry-After: 0 Cache-Control: no-store AAuth-Requirement: requirement=interaction; url="https://mcp.example/consent"; code="A1B2-C3D4"Interaction codes: Crockford base32, ≥40 bits, single-use, rate-limited; support
?code=...&callback=...and redirect tocallback?error=...on failure. Pending URLs: unguessable, same-origin, verify the agent’s signature on every poll,410after a terminal response. - On success return
200withAAuth-Access: <token68>— an opaque wrapper of your internal token (an existing OAuth access token, a session, whatever), never usable as a bare bearer token. The agent replays it asAuthorization: AAuth <token68>and must coverauthorizationin its signature — reject the request if it isn’t covered. You can rotate it by returning a freshAAuth-Accesson any later response.
Checklist — rung 2: ☐ 202 + interaction for consent ☐ single-use codes +
same-origin pending URLs ☐ return AAuth-Access wrapping your token ☐ require
authorization to be covered on subsequent calls.
5. Rung 3 — accept user identity from the agent’s Person Server
Now you get a real end user behind the agent, without running an IdP. You need
signing keys (and jwks_uri in metadata).
a. Verify a person token first. Since AAuth -11 a resource token is issued
from a person token, not from an agent token. Challenge with
401 AAuth-Requirement: requirement=person-token; the agent presents an
aa-person+jwt in Signature-Key in place of its agent token, and you verify it
against the Person Server’s published JWKS.
b. Issue a resource token — either from your authorization_endpoint
({"resource_token":"..."}) or as a challenge
401 AAuth-Requirement: requirement=auth-token; resource-token="eyJ...".
It’s an aa-resource+jwt you sign:
{
"iss": "https://mcp.example", "dwk": "aauth-resource.json",
"aud": "<the person token's `iss`>", // three-party: the agent's PS
"jti": "…",
"ps": "<iss of the person token you verified>",
"sub": "<sub from that person token>",
"presented_jti": "<jti of that person token>",
"iat": …, "exp": "≤ 5 minutes",
"scope": "tools.read tools.exec"
}
presented_jti is what makes the token resolvable. The Person Server looks
that jti up against its own record of the person token it issued; without it
you get 400 invalid_resource_token: missing presented_jti and the exchange
stops. It is also what makes mission stripping detectable, so it is not
bookkeeping.
Two ways to get this wrong, both seen in the wild:
- Carrying
agentoragent_jkt. Removed in-11— a resource token now carries no agent identifier at all. Older examples still show them. - Naming the field
person_token_jti. That was its name before-11, which renamed it topresented_jtiwith the value unchanged. Emitpresented_jti. If you are writing a Person Server, accept both: real resources still send the old name, and rejecting them buys nothing.
(Four-party: set aud to your Access Server instead; the flow on your side is
otherwise identical. Missions are no longer agent-asserted — the AAuth-Mission
header was removed in AAuth -11. A mission now reaches you only as the
mission_s256 claim inside a PS-issued token, which you copy into the resource
token you issue.)
c. Verify the auth token the agent then presents
(Signature-Key: sig=jwt;jwt="<auth token>"). Two independent checks:
- JWT trust:
typ == "aa-auth+jwt";dwk∈ {aauth-person.json(PS),aauth-access.json(AS)}; verify signature via the issuer’s JWKS;exp/iat. - Request-context binding:
aud== your issuer;agentmatches the request’s signer;cnf.jwk== the key that signed the HTTP request (reject a structurally-incompletecnf);actchain sane; at least one ofsub/scopepresent; grantedscope⊆ what you asked for.
d. Apply your own policy. In three-party you trust the agent’s chosen PS for
identity claims only — you still enforce access. Namespace users by
(iss, sub) (plus tenant if present): look up the tuple, create a user record
on a miss, match on a hit. Registration and login are the same flow — your logic
distinguishes the outcomes. The sub is pairwise per resource by design; don’t
expect it to match another service’s sub.
You may step up at any time (e.g. return a fresh requirement=auth-token with a
broader scope) even against a valid auth token.
Checklist — rung 3: ☐ verify a person token before issuing anything ☐ sign
resource tokens (aud = agent’s ps, ≤5 min, carrying ps/sub/presented_jti,
no agent identifier) ☐ verify auth tokens (JWT trust + context binding, scope ⊆
requested) ☐ key users by (iss, sub[, tenant]) ☐ enforce your own policy.
6. Optional: emit events to agents (AAuth Events)
If your server has async outcomes (long jobs, availability changes), deliver via the agent’s AP (the agent has no public URL):
- Accept subscriptions: a signed request whose
Signature-KeyJWT is anaa-subscribe+jwtissued by the agent’s AP. Verifytyp, AP JWKS via{iss}/.well-known/aauth-agent.json,exp,aud== your URL,cnf.jwk== the HTTP signer, non-emptyeid. Store{eid, iss(=AP), sub(=agent)}; dedupe byeid. For protected channels, hand out single-use ticket URLs from an authenticated call and verifysubmatches. - On event fire: mint an
aa-event+jwt{iss:you, dwk:"aauth-resource.json", aud:<agent id>, eid, iat, exp:<response deadline>}and POST it to the AP’sevent_endpoint(resolve fresh from{ap}/.well-known/aauth-agent.json), presenting the event token asSignature-Key: sig=jwtand signing the HTTP request with the same resource key (thedwk-without-cnfpattern). Body = your AsyncAPI-described payload (keep sensitive data out; agents fetch specifics via authed calls). - Handle AP responses:
202(+remaining_useswhen the sub hadmax_uses;0⇒ clean up),404unknown/expired eid ⇒ drop the sub,403you’re not the authorized resource,429uses exceeded. - Describe channels with AsyncAPI, advertise
urn:aauth:vocabulary:asyncapi.
7. Trusting Agent Providers
Whether to honor tokens from a given AP is your policy decision, keyed on the
token’s iss. Signals: the AP’s metadata (name/description/logos/ToS), your
history with agents from that iss, and any AP-added claims (attestation, etc. —
only as trustworthy as the AP that signed them). Self-hosted APs (a fleet of one)
are first-class — same verification path; the trust call is per-iss. Maintain
an allow/deny policy over issuers just as you would over API-key issuers.
The assurance claim. apd stamps an assurance tier
(none/low/medium/high) reflecting how the agent enrolled — a
software-key/open enrollment ranks low, a SPIFFE/PKI-attested workload ranks
high. Treat it exactly like any other AP-asserted claim: it is only as
trustworthy as the AP that signed the token, so weigh it together with your
trust in that iss. A useful gate for a sensitive tool is “require
assurance in {medium, high} from APs I don’t fully trust.” Absent the claim,
default to your baseline policy.
8. Common pitfalls
- Rewriting Host or path at a proxy breaks verification — the signature
covers
@authorityand@path. Preserve them; don’t stripSignature-*. - Trusting an auth token as a bearer — always verify
cnf.jwkbinds it to the request’s signer, and enforce your own policy on the claims. - Skipping the issuer-match check on fetched metadata — that’s the host-poisoning defense; without it an attacker can point you at their keys.
- No egress admission on JWKS fetches — the issuer/
jwks_uricome from a token; block private IPs and cross-host redirects (SSRF). - Treating
subas global — it’s pairwise per resource; namespace by(iss, sub). - Blocking on human input — return
202 interactionand let the agent drive the user in.