AAuth — cryptographic identity for agents (experimental)
How MCPG uses the AAuth drafts to identify agents, the people they act for, and what they are allowed to do — the access modes, how each is configured, and the nuances that decide whether it works.
Draft protocol, experimental support. AAuth is a family of IETF Internet-Drafts by Dick Hardt —
draft-hardt-oauth-aauth-protocol(-10published,-11in the editor's copy) on top ofdraft-hardt-httpbis-signature-key(-08). Nothing here is an RFC yet, and the wire format has moved between revisions (theEdDSA→Ed25519switch, the person token, renamed endpoints). MCPG tracks the current drafts and interoperates with the reference implementations, but the surface is experimental: expect the configuration to follow the drafts as they change, keep the plugin and gateway on the same release, and do not build irreversible workflows on the parts marked draft-only below. Feedback on the drafts belongs at github.com/dickhardt/AAuth; on MCPG's implementation, in this repository.
Why a gateway would want this
Every identity source MCPG already speaks — OIDC, JWKS, mTLS, SPIFFE, API keys — answers "which human?" or "which workload?". An AI agent calling MCP tools is neither: it is software that discovers services at runtime, acts on behalf of a person, and needs to be told no mid-task. Today it borrows a human's OAuth token or a static API key, and the resource cannot tell the agent from the person, cannot revoke one agent instance without the others, and has no cryptographic proof that the request came from the key it was issued to.
AAuth gives the agent its own identity — aauth:local@domain, bound to a signing key —
and puts an RFC 9421 HTTP Message Signature on
every request, so a token is useless without the key that signed the call. On top of that
identity it layers who the agent acts for (a person server) and what it may do
(consented scopes), all discoverable through well-known documents with no pre-registration.
MCPG is the Resource in AAuth's vocabulary: the thing being called. It never becomes an agent provider or a person server. Three parties matter to it:
flowchart LR AP["Agent Provider (AP)<br/>issues agent tokens<br/>publishes aauth-agent.json"] PS["Person Server (PS)<br/>represents the person<br/>publishes aauth-person.json"] Agent["Agent<br/>aauth:local@domain<br/>signs every request"] MCPG["MCPG (Resource)<br/>verifies, decides, serves<br/>publishes aauth-resource.json"] AP -- "agent token" --> Agent PS -- "person token,<br/>auth token" --> Agent Agent -- "signed MCP request" --> MCPG MCPG -. "key discovery only" .-> AP MCPG -. "key discovery,<br/>revocations arrive here" .-> PS
Every well-known document sits under /.well-known/ on the party's own origin; the tokens
are aa-agent+jwt, aa-person+jwt, and aa-auth+jwt, and every signed request carries
its token in Signature-Key: sig=jwt;jwt="…".
The dotted lines are the whole trust model: MCPG fetches the AP's and PS's public keys through their well-known documents and verifies signatures locally. It never asks either server "is this token good?" — which is why AAuth needs no shared secrets, no client registration, and no callback from the resource except the revocation endpoint it may choose to publish.
The building blocks in MCPG
| Piece | Where | Role |
|---|---|---|
dev.mcpg.identity.aauth | identity plugin | Verifies. Parses the RFC 9421 signature, checks the JWT in Signature-Key, resolves keys through discovery (SSRF-hardened, cached), binds the request to the token's key, and yields the gateway identity — agent, person, or grant. |
server.aauth_resource_metadata | gateway config | Declares and issues. Publishes /.well-known/aauth-resource.json, the gateway's signing key (JWKS), an authorization endpoint that mints resource tokens, a revocation endpoint, and the AAuth-Requirement challenges that tell an agent what to bring. |
mcpg inspector | client | Acts as the agent for testing: signs with an agent token, obtains person and auth tokens from a person server, drives consent. |
mcpg-aauth-core | shared crate | One implementation of the wire format for signer and verifier, so they cannot disagree about the signature base. |
Everything an AAuth caller resolves to is an ordinary MCPG identity at trust_level: verified — the trust floor, CEL policy, tool-gate plugins, quotas, and audit see it
exactly as they see an OIDC subject.
Which access mode do you want?
AAuth defines five access modes — not levels of adoption, but answers to "what does the resource need to know before it serves a request?". MCPG supports three; two are out of scope for a gateway.
| Mode | The resource learns | Established by | MCPG |
|---|---|---|---|
| Agent identity | which agent | the agent provider | ✅ trusted_issuers |
| Person identity | which person the agent acts for | the person server | ✅ person_tokens |
| PS authorization (three-party) | person + consented scope | the person server | ✅ auth_tokens + resource role |
| Federated authorization (four-party) | person + a policy verdict | the resource's own access server | ➖ verifies AS-issued grants only (trusted_access_servers) |
| Resource-managed (session token) | whatever the resource's own login learned | the resource's own flow | ✖ not planned — MCPG has no user login of its own |
They compose: enabling person tokens does not stop agent tokens from verifying, and a person-token caller reaches every tool that needs only identity while being stepped up on the tools that need scope. Choose by what your tools need to know.
Approach 1 — agent identity: replace API keys
The simplest deployment. An agent provider issues the agent a token binding its key to
aauth:local@domain; the agent signs each request; MCPG verifies and applies its own
policy to who the agent is. No consent, no person, no scopes.
sequenceDiagram autonumber participant AP as Agent Provider participant A as Agent participant G as MCPG A->>AP: enrol (platform-specific) AP-->>A: agent token (aa-agent+jwt, cnf = agent key) A->>G: POST /mcp<br/>Signature-Input, Signature,<br/>Signature-Key: sig=jwt#59;jwt="…" G->>AP: GET /.well-known/aauth-agent.json → jwks_uri (cached) G->>G: verify token (typ, iss, exp, cnf.jwk alg)<br/>verify request signature under cnf.jwk G-->>A: 200 — identity: subject_id = aauth:local@domain,<br/>issuer = AP, trust = verified
plugins:
- id: dev.mcpg.identity.aauth
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/source-code/plugins/identity-aauth:1.0.0"
granted_capabilities:
- network_outbound # metadata + JWKS discovery
config:
trusted_issuers: # exact `iss`, scheme included, no trailing slash
- https://sandbox.agentprovider.dev
signature_window_secs: 60 # ±created; keep clocks NTP-synced
mcp:
capabilities:
tools:
- name: search.docs
governance:
minimum_trust: verified # anonymous / header-asserted callers never see it
backend: { kind: http, url: "https://search.internal/query", method: post }
Policy keys on identity.issuer (the AP), identity.subject_id (the agent id — domain-
bound to the issuer, so it cannot be spoofed across providers), and the attributes the
plugin adds: aauth.token_type = agent, aauth.jti, aauth.agent_jkt, aauth.ps (the
agent's declared person server, if any), aauth.parent_agent (sub-agents).
To try it without a real provider, the inspector can be its own agent provider (the
draft's self-hosted agent pattern): mcpg inspector aauth-keygen --agent aauth:me@agents.example --issuer https://agents.example --publish ./ap writes the two
static documents an AP is, and any static host serves them.
Trust posture. trusted_issuers is a fail-closed allow-list — an empty list refuses
to load. allow_any_issuer: true accepts every AP that publishes valid discovery, which
means anyone who can host two JSON files can assert an agent identity: only ever use it
with policy that keys on (issuer, subject_id) and never on the subject alone.
Approach 2 — person identity: whose agent is it?
A person server (PS) is the party the person chose to represent them. It issues the agent
a person token for one resource: aud = MCPG, sub = a directed identifier for
(person, resource) — the same person shows a different sub to every resource, so
resources cannot correlate them — bound to the agent's key. The first person token for a
new resource normally requires the person's consent at the PS.
sequenceDiagram
autonumber
participant P as Person
participant PS as Person Server
participant A as Agent
participant G as MCPG
A->>PS: POST /person {resource: "https://mcp.example"}<br/>signed with the agent token
PS-->>A: 202 Accepted<br/>Location: /pending/…<br/>AAuth-Requirement: requirement=interaction#59; url#59; code
A-)P: "open url?code=… and decide"
P->>PS: sign in, consent to this agent at this resource
A->>PS: GET /pending/… (poll, Prefer: wait)
PS-->>A: 200 {person_token}
A->>G: POST /mcp signed, Signature-Key: sig=jwt#59;jwt="«person token»"
G->>PS: discovery + JWKS (cached)
G->>G: typ = aa-person+jwt, aud = my identifier,<br/>iss ∈ trusted person servers, cnf binds the request
G-->>A: 200 — identity: subject_id = directed sub, issuer = PS
plugins:
- id: dev.mcpg.identity.aauth
class: identity_provider
granted_capabilities: [network_outbound]
config:
trusted_issuers: ["https://sandbox.agentprovider.dev"]
person_tokens:
enabled: true
resource_identifier: https://mcp.example # this gateway's AAuth identifier — the token's aud
trusted_person_servers:
- https://sandbox.personserver.dev
Now the principal is the person, keyed as (issuer = the PS, subject_id = the directed sub); the agent's identity is deliberately absent from the token (the draft's rationale:
a person's relationship with a resource should survive a change of agent). Attributes:
aauth.token_type = person, aauth.ps, aauth.mission_s256 when the agent works under
an approved mission, aauth.tenant for organisational context.
Two things to internalise:
- The PS allow-list is explicit only — there is no
allow_anyfor person servers, because a PS asserts who a person is; trusting an arbitrary one hands the assertion of identity to whoever hosts it. - Identity is not authorization. A person token proves "the same person again", not
legal identity, and grants no scope. Tools that need only identity serve on it; a tool
with
required_scopesrefuses it — and, in the next approach, tells the agent how to get the grant.
Approach 3 — PS authorization: what may it do? (three-party)
This is the mode a person server is for, and the one MCPG's resource role exists to
serve. The resource asks for consented scope by minting a signed resource token; the
person server turns it into an auth token carrying scope; MCPG enforces the grant.
sequenceDiagram
autonumber
participant P as Person
participant PS as Person Server
participant A as Agent
participant G as MCPG
A->>G: tools/call deploy.rollback<br/>signed with the person token
G->>G: policy: deploy.rollback requires scope tools:write — caller has none
G-->>A: 401<br/>AAuth-Requirement: requirement=auth-token#59; resource-token="«aa-resource+jwt»"
Note over G: the resource token names: iss = MCPG, aud = PS,<br/>ps + sub + presented_jti (the person token), agent_jkt, scope, ≤ 5 min
A->>PS: POST /token {resource_token} signed with the agent token
PS->>G: GET /.well-known/aauth-resource.json → jwks_uri → verify MCPG's signature
PS->>PS: presented_jti names a person token it retains#59;<br/>ps/sub/mission/tenant match#59; scope ⊆ consented?
PS-->>A: 202 … consent (P approves scope at the PS) … 200 {auth_token}
A->>G: tools/call deploy.rollback<br/>signed with the auth token
G->>G: typ = aa-auth+jwt, iss = PS = ps, aud = me,<br/>cnf binds the request#59; scope → identity.scopes
G-->>A: 200
Two things must be configured — the plugin (verify grants) and the gateway's resource role (issue resource tokens):
gateway:
server:
aauth_resource_metadata:
issuer: https://mcp.example # = person_tokens.resource_identifier
access_mode: auth-token # what agents should expect here
signing_key: { seed: "${env.AAUTH_RESOURCE_SEED}" } # 32-byte Ed25519 seed, base64url
scope_descriptions: # the scopes this resource grants — consent shows these
"tools:read": "Read-only tools"
"tools:write": "Tools that change things"
name: "Example gateway"
documentation_uri: https://mcp.example/docs
plugins:
- id: dev.mcpg.identity.aauth
class: identity_provider
granted_capabilities: [network_outbound]
config:
trusted_issuers: ["https://sandbox.agentprovider.dev"]
person_tokens:
enabled: true
resource_identifier: https://mcp.example
trusted_person_servers: ["https://sandbox.personserver.dev"]
auth_tokens:
enabled: true # PS-issued grants (dwk aauth-person.json)
trusted_access_servers: [] # four-party ASes, if the resource has one
mcp:
capabilities:
tools:
- name: deploy.rollback
governance:
minimum_trust: verified
required_scopes: ["tools:write"] # the person must have consented to this
backend: { kind: http, url: "https://deploy.internal/rollback", method: post }
What the gateway now publishes and does:
| Surface | Purpose |
|---|---|
GET /.well-known/aauth-resource.json | issuer, access_mode, scope_descriptions, accept_signature_algs, jwks_uri, authorization_endpoint, revocation_endpoint — an agent that knows only the hostname can plan the flow; the PS reads name/description/scope_descriptions for its consent screen. |
GET /.well-known/aauth-jwks.json | The public half of signing_key. Person servers verify resource tokens against it, so it must be on the same host as issuer (the reference PS refuses cross-origin JWKS). |
POST /aauth/authorize | The draft's authorization endpoint: a caller presenting a person token asks for {scope} and receives a resource token. Anything else is answered 401 requirement=person-token. |
401 requirement=auth-token; resource-token=… | The same resource token, minted on the spot when a person or auth-token caller lacks a tool's required_scopes — for the scopes it holds plus the ones it lacks, so consent accumulates rather than resets. |
POST /aauth/revoke | Where the PS tells MCPG a token is revoked (below). |
Scopes are ordinary MCPG scopes once verified: required_scopes on a binding, "tools:write" in identity.scopes in CEL, identity_claim-keyed quotas, and the audit event all carry
them. A resource token may only name scopes you declared in scope_descriptions (plus the
standard OpenID identity scopes); a tool whose required_scopes names something outside
that map can never be satisfied through AAuth — the challenge silently drops unknown
scopes, so keep the two lists aligned.
Revocation: telling the resource "no" in real time
Verifying a token never asks the issuer about it, so a resource learns of a revocation
only if the issuer pushes one. The person server does exactly that when the person
revokes the agent (or an agent provider revokes an agent token, or a mission ends): a
POST {"iss","jti"} to MCPG's revocation_endpoint, signed by the PS as itself.
sequenceDiagram
autonumber
participant P as Person
participant PS as Person Server
participant G as MCPG
participant A as Agent
P->>PS: revoke this agent
PS->>G: POST /aauth/revoke {iss: PS, jti: "at-…"}<br/>Signature-Key: sig=jwks_uri#59;id=PS#59;dwk=aauth-person.json#59;kid=…<br/>covers content-type + content-digest
G->>PS: discovery + JWKS for the signer (id must equal iss)
G->>G: verify signature, verify Content-Digest against the body,<br/>record (iss, jti) as revoked
G-->>PS: 200 {revoked: true}
A->>G: tools/call signed with that auth token
G-->>A: 401 Signature-Error: error=invalid_jwt
Only the issuer of a token may revoke it (id in the signature must equal iss in the
body), which is also what bounds the deny-list an issuer can create. The record lives in
the replica that received it — a fleet is told per replica the PS reaches — and expires
with the longest token lifetime the protocol allows, which is the exposure bound the
drafts themselves rely on (auth and person tokens ≤ 1 h). For operator-driven revocation
across a fleet, the plugin's revoked_tokens: [{iss, jti}] list is applied from config.
Discovery and challenges: how an agent finds its way in
An AAuth agent runs one loop: make the request, read AAuth-Requirement, satisfy it,
retry. MCPG's side of that loop, decided by access_mode:
| Situation | Response |
|---|---|
Unauthenticated caller at a verified tool, access_mode: agent-token | 401 + AAuth-Requirement: requirement=agent-token + Accept-Signature-Scheme: jwt + Accept-Signature-Alg: Ed25519, ES256 |
Same, access_mode: person-token or auth-token | 401 + requirement=person-token (the draft: a resource that authorizes on the person must be handed a person token before anything else) |
| Person / auth-token caller short of a tool's scopes | 401 + requirement=auth-token; resource-token="…" |
| A presented credential fails verification | 401 + Signature-Error: error=<code> (invalid_jwt, expired_jwt, unsupported_scheme, unknown_key, issuer_mismatch, …) with Accept-Signature-* on the recoverable ones |
| An authenticated caller denied by policy | 403, untouched — a 403 is terminal to an AAuth agent, and this one is |
Note the first row: MCPG normally answers an anonymous caller at the trust floor with a
policy 403. Declaring aauth_resource_metadata changes that to the protocol's 401
challenge for unauthenticated callers only, because that is the only way an AAuth agent
learns which credential to bring — and 401 is what "no credential" means in HTTP anyway.
WWW-Authenticate: Bearer … rides alongside; the two headers are independent by design.
Trying it end to end
The mcpg inspector is the agent. Against the public sandboxes
(agentprovider.dev for identity,
personserver.dev for the person — the
latter needs a test person with a passkey, requested from its operator):
# 1. an identity — the sandbox AP enrols an agent and returns a key + token
# (or self-issue with `mcpg inspector aauth-keygen … --publish DIR`)
# 2. agent identity: sign and call
mcpg inspector call https://mcp.example/mcp search.docs \
--aauth-key "$SEED" --aauth-token "$AGENT_TOKEN"
# 3. person identity: obtain a person token first (consent prints a URL + code)
mcpg inspector call https://mcp.example/mcp search.docs \
--aauth-key "$SEED" --aauth-token "$AGENT_TOKEN" \
--aauth-person-server https://sandbox.personserver.dev --aauth-credential person
# 4. consented scope: person token → resource token → auth token → call
mcpg inspector call https://mcp.example/mcp deploy.rollback \
--aauth-key "$SEED" --aauth-token "$AGENT_TOKEN" \
--aauth-person-server https://sandbox.personserver.dev \
--aauth-credential auth --aauth-scopes "tools:write" \
--aauth-save-credential ./auth.jwt
# 5. reuse the grant until it expires (≤ 1 h) or is revoked
mcpg inspector call https://mcp.example/mcp deploy.rollback \
--aauth-key "$SEED" --aauth-present "$(cat auth.jwt)"
The inspector's auth lab (mcpg inspector auth-lab URL) reports what a resource
advertises — access_mode, Accept-Signature-*, Signature-Error — without driving the
consent modes. The repository's e2e/inspector/aauth-person-server-e2e.sh runs the whole
loop above against an in-tree stub person server, and against the reference psd when
PSD_BIN points at it.
Nuances that decide whether it works
Identifiers are exact strings. A server identifier is https://host — lowercase, no
port, no path, no trailing slash. person_tokens.resource_identifier and
aauth_resource_metadata.issuer are the same value, and it is what the PS puts in aud
and derives the directed sub for. A gateway reachable at https://mcp.example/mcp is
the resource https://mcp.example, and the reference PS refuses anything with a path.
Development set-ups use insecure_dev_mode on both plugin and metadata block to admit
http:// and a port.
Proxies break signatures. The signature covers @method, @authority, @path, and
signature-key. Preserve Host and the path at every hop and never strip Signature-*;
where a TLS terminator rewrites Host, pin expected_authority in the plugin to what
agents actually sign. An agent dialling http://host:443 signs host:443 — the override
is taken verbatim for exactly that reason.
Clocks. created must sit inside signature_window_secs (60 s by default) of the
gateway's clock, and tokens carry iat/exp. Both ends need NTP.
Algorithms are fully specified. Ed25519 — never the polymorphic EdDSA the drafts
now forbid — in every JWT header, in cnf.jwk, and in every published JWKS key; ES256
is accepted for agents whose hardware keys are P-256. A JWKS key without alg is
unusable, even though RFC 7517 makes the member optional. There is no flag to relax any of
this: the drafts say MUST NOT, and a switch would be a switch to non-compliance.
The body is not signed unless you ask. An identity resolver binds the request
envelope; content-digest binds the header value only where the receiver checks it
against the bytes. Require it via additional_covered_components (alias:
additional_signature_components, the metadata field name) and verify it downstream for
state-changing calls; the inspector sends it by default. replay_protection: true adds
the drafts' optional replay cache — pair it with the digest, because created has one-
second granularity.
Person sub is opaque and per-issuer. Key policy, quotas, and records on
(identity.issuer, identity.subject_id); the same string under another PS is a
different person. identity.issuer is bound into CEL for precisely this.
Scopes must be declared to be grantable. scope_descriptions is the set a resource
token may name; required_scopes on a tool should draw from it. Consent at the reference
PS is cumulative per (person, agent, resource): a second grant for a superset asks
again, a subset is served from record — which is why MCPG's step-up requests held ∪
missing scopes.
Consent may or may not defer. A person server answers 202 and hands the agent an
interaction URL and code, or — the reference implementation honours Prefer: wait — holds
the request and answers 200 if the person decides in time. Agents (and the inspector)
handle both.
Revocation is best-effort and per replica. Token lifetimes are the real bound; revocation shortens exposure. Issue short-lived grants where immediacy matters.
Nothing here is a bearer token. A captured person, auth, or agent token cannot be
replayed from another key; the request signature under cnf.jwk is what authenticates.
This is also why Authorization: Bearer clients and AAuth agents can share one gateway
without interference — the two challenge headers coexist, and each client processes the
one it understands.
What is deliberately not implemented
- Session tokens (
AAuth-Access, resource-managed mode) — MCPG has no user login of its own to wrap; the person server is that login. - Missions, permission, audit and interaction endpoints — these are person-server
surfaces. MCPG surfaces
aauth.mission_s256so policy can require or pin a mission, and passes nothing else through. - Four-party federation on MCPG's behalf — MCPG verifies auth tokens an access server issued when it is allow-listed, but does not run one.
- The exploratory companion drafts (R3 rich resource requests with its MCP tool vocabulary, budgets, events) — tracked; not built until they leave exploratory status.
Where to read next
- Identity — the plugin's full option reference alongside the other identity sources.
- Policy — trust floors,
required_scopes, CEL overidentity.*, and the external policy engines that see the same attributes. - The drafts: AAuth protocol, HTTP Signature Keys, and the specification repository with the reference implementations, agentprovider.dev and personserver.dev.