Security
Securitybeta14 min

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 (-10 published, -11 in the editor's copy) on top of draft-hardt-httpbis-signature-key (-08). Nothing here is an RFC yet, and the wire format has moved between revisions (the EdDSAEd25519 switch, 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:

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

PieceWhereRole
dev.mcpg.identity.aauthidentity pluginVerifies. 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_metadatagateway configDeclares 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 inspectorclientActs as the agent for testing: signs with an agent token, obtains person and auth tokens from a person server, drives consent.
mcpg-aauth-coreshared crateOne 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.

ModeThe resource learnsEstablished byMCPG
Agent identitywhich agentthe agent providertrusted_issuers
Person identitywhich person the agent acts forthe person serverperson_tokens
PS authorization (three-party)person + consented scopethe person serverauth_tokens + resource role
Federated authorization (four-party)person + a policy verdictthe resource's own access server➖ verifies AS-issued grants only (trusted_access_servers)
Resource-managed (session token)whatever the resource's own login learnedthe 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.

yaml
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.

yaml
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_any for 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_scopes refuses 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.

Two things must be configured — the plugin (verify grants) and the gateway's resource role (issue resource tokens):

yaml
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:

SurfacePurpose
GET /.well-known/aauth-resource.jsonissuer, 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.jsonThe 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/authorizeThe 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/revokeWhere 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.

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:

SituationResponse
Unauthenticated caller at a verified tool, access_mode: agent-token401 + AAuth-Requirement: requirement=agent-token + Accept-Signature-Scheme: jwt + Accept-Signature-Alg: Ed25519, ES256
Same, access_mode: person-token or auth-token401 + 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 scopes401 + requirement=auth-token; resource-token="…"
A presented credential fails verification401 + 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 policy403, 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):

sh
# 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_s256 so 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