ADR-007 §2 · epic #181 · the agent-native authorization layer.
QAuth is an OAuth 2.1 authorization server for MCP servers and AI agents. The
MCP Quickstart and OAuth 2.1 Flow cover
the generic handshake any client uses. This guide covers the agent-native
layer on top of it — the five pieces that let an autonomous agent act on
behalf of a user with bounded, auditable authority:
The unifying principle is default-deny on untrusted input: a client can
claim to be an agent, but every capability that claim would unlock is gated by
an independent, server-side criterion. A client that omits is_agent to dodge
controls simply is not an agent and gets nothing extra; a client that asserts it
still cannot exceed what an operator provisioned for it.
ℹ️ Ask for the delegation grant explicitly. The registration above gets you
a client, but §2’s on-behalf-of delegation additionally requires
urn:ietf:params:oauth:grant-type:token-exchange in grant_types — POST /oauth/token gates delegation on client.grantTypes.includes(...), so a
client without it is refused with unauthorized_client:
The client must be confidential. Token exchange authenticates the agent
through the confidential client-auth path, so
token_endpoint_auth_method: "none" is refused at registration rather than
at every exchange. On CIMD, where a client is public unless it publishes a
key set for private_key_jwt, the grant is dropped from the materialised row
instead — CIMD documents describe a client across every AS it talks to, so an
unusable-here grant is not an error.
response_types must be [] for a token-endpoint-only registration.
That is not specific to this grant — client_credentials alone needs it too,
because the normalizer refuses a code response type without the
authorization_code grant.
Holding the grant confers no authority by itself. The exchange still requires a
valid subject token you already hold, preserves-or-narrows its scope and
audience, and clamps any reserved agent:* scope to the operator-set
max_agent_mode — which self-registration never sets. See §2.
(Before #381 this was impossible: the grant was advertised and enforced but
rejected by every provisioning path, so it had to be written straight into the
oauth_clients.grant_types JSONB column.)
is_agent is client-controlled input — the client sets it in its own DCR
body or CIMD document. QAuth therefore never trusts it alone. It is read
through a fail-closed accessor (isAgentClient(client), which is true only for
an explicit isAgent === true, never a missing/null value), and on its own it
gates nothing dangerous. Every agent capability requires a second,
server-side condition:
Token Exchange additionally requires confidential-client authentication
and that the subject token was minted for this agent (see §2).
Scope modes additionally require an operator-set max_agent_mode cap that
covers the requested mode (see §3) — and that cap is not accepted from the
client’s own registration request.
The safe outcome is the same whether a client lies, omits, or sends a malformed
value: no agent privilege.
On-behalf-of delegation is an MCP auth extension
(ext-auth), not core MCP —
QAuth provides it as a value-add.
An agent exchanges a user’s access token (subject_token) for a delegated
access token whose sub remains the user and whose act (actor) claim
identifies the agent. Chained delegation nests act (RFC 8693 §4.1).
The delegated access token carries an RFC 8693 §4.1 act claim. The current
actor (the authenticated agent’s own client_id) is the outermost act; any
pre-existing delegation chain on the subject token nests beneath it:
{
"sub": "<user-id>", // the delegated token's subject stays the USER
"act": {
"sub": "AGENT_CLIENT_ID", // the agent acting now
"act": { "sub": "UPSTREAM_AGENT_ID" }, // a prior actor, if re-exchanged
},
}
The act.sub is always the authenticated agent’sclient_id — never a
value self-declared in an actor_token. Access tokens also carry a
token_use: "access" marker.
These guarantees hold even against an attacker who has captured a user’s token
and knows a public agent client_id:
Agent-only. The client must be classified as an agent
(isAgentClient(client), fail-closed) and be granted the token-exchange
grant type. Otherwise → unauthorized_client.
Confidential clients only. Token exchange requires confidential client
authentication (client_secret_basic / client_secret_post). A public agent
(token_endpoint_auth_method=none) presents no secret and is rejected with
invalid_client. This is a deliberate security floor (RFC 9700): on-behalf-of
delegation must not be mintable by a client that proves only knowledge of a
client_id.
Subject token must be bound to the agent. The subject_token must verify
as a QAuth-issued access token — EdDSA signature + exp, QAuth’s own
iss, and an access-token use marker (ID tokens and other JWTs are rejected
with invalid_request) — and its aud must contain the requesting
agent’s client_id. Combined with the confidential-client requirement, this
closes the unauthorized-delegation gap: a captured token alone is not enough.
The subject user must also still exist and be enabled.
Scope and audience are narrowed, never widened. A requested scope must
be a subset of the subject token’s scope (else invalid_scope); resource /
audience must fall within the subject token’s aud (else invalid_target).
Scope-mode cap still applies. Any reserved agent:* scope that survives
narrowing is additionally checked against the agent’s server-side
max_agent_mode (see §3), so a capped agent cannot launder a higher mode
through delegation even when the subject token carried it. An over-cap scope
rejects the whole exchange with invalid_scope; it is never silently
reduced to the cap.
Lifetime never exceeds the subject token.expires_in is clamped to
min(configured_lifespan, subject_token_remaining), so a delegated token can
never outlast — or be re-exchanged to extend — the authority it derives from.
Bounded delegation depth. Each re-exchange nests another act. The chain
is capped at 4 actors; a deeper request → invalid_request.
No refresh token is issued — a delegated token is intentionally
short-lived; the agent re-exchanges as needed.
Every exchange is audited (success and failure), including the actor and
delegation depth (see §5).
QAuth bounds what an agent can do with a small, opinionated capability taxonomy
layered over raw OAuth scopes, so operators get a coarse, legible control
instead of hand-curating scope strings.
Cap ordering — which modes a client is allowed to request — is ReadOnly <
Admin < Exec. A client whose maximum mode is admin may request
agent:readonly and/or agent:admin, but not agent:exec. The cap is a
maximum, not an exact match. This ordering is a provisioning ceiling only:
it says what a client may ask for, never that one granted scope stands in for
another.
The modes are plain OAuth scopes (agent:*), so the existing scope
machinery enforces them with no new claim system — the per-client allowlist and
validateScopes at the token endpoint, scope filtering at the authorize /
consent screens, and @qauth-labs/mcp-guard’s resource-server scope checks. The
granted mode is therefore already visible to resource servers in the token’s
scope set.
The three modes are independent scopes, not a hierarchy
Corrected 2026-08-06. This guide previously described agent:admin as
”⊇ ReadOnly” and the ordering as “ReadOnly ⊂ Admin ⊂ Exec”. That reading is
wrong at enforcement time, and a client built on it will break.
The three reserved scopes are three ordinary, independent OAuth scopes. A
broader mode does not imply a narrower one:
@qauth-labs/mcp-guard matches scopes exactly and case-sensitively per
RFC 6749 §3.3, with no hierarchical or prefix semantics (missingScopes in
libs/fastify/plugins/mcp-guard/src/lib/scope.ts). This is deliberate and is
not changing.
A token carrying only agent:admin therefore does not satisfy a route
that requires agent:readonly — it gets a 403 insufficient_scope, exactly
as it would for any other unrelated missing scope.
A client that needs both must request both, e.g.
scope=agent:readonly agent:admin. Being capped at admin permits that
request; it does not perform it for you.
max_agent_mode is a maximum requestable ceiling, not an implication
relation between the scopes.
A reserved agent:* scope is granted only when both hold:
the client is classified as an agent via the fail-closed isAgentClient
accessor (isAgent === true, never missing/null), and
the requested mode is within the client’s server-sidemax_agent_mode
cap.
oauth_clients.max_agent_mode is operator-set server state — provisioned
via the seed script or admin, and never accepted from a client’s own
registration request (an escalation control must not be self-asserted). It is
absent from the DCR request and response schemas entirely. It defaults to
NULL, meaning no agent mode is permitted.
Consequences (all fail-closed):
A client that omitsis_agent is not an agent → noagent:* scope.
A client with no cap (NULL) gets noagent:* scope — not even
ReadOnly.
An unknown stored mode parses to null and denies.
A capped agent cannot obtain a higher mode (admin-capped → agent:exec
rejected with invalid_scope).
mcp-guard emits the runtime 403 insufficient_scope + WWW-Authenticate
challenge when a resource needs a scope the presented token lacks (shipped in
T1). The authorization-server half of that loop lives in /oauth/authorize:
when a client returns asking for an increased scope set, QAuth must not
silently widen an existing grant. Crossing into a more-privileged scope set is a
step-up — it requires a fresh authentication and/or explicit
re-consent (MCP 2026-07-28 incremental consent — the “Step-Up Authorization
Flow”).
Evaluated from the requested scopes versus what a prior consent already covers,
plus the standard OIDC prompt / max_age request parameters:
Any newly-requested scope beyond a prior consent ⇒ re-consent
required (never auto-widen).
prompt=consent ⇒ re-consent even with no elevation.
Any dangerous scope being granted — whether newly elevated or already
covered by a remembered prior consent ⇒ fresh login required. A
state-changing capability is only granted right after the user proves
presence; a stale grant cannot satisfy it, and no client opt-in is involved.
prompt=login ⇒ fresh login.
max_age present and the session is older than it ⇒ fresh login
(max_age=0 demands a brand-new authentication; second-granularity flooring
makes the post-login round-trip terminate instead of looping).
When the caller forbids UI but step-up would otherwise display a screen, the
authorize endpoint returns the matching OIDC error as an error= redirect
instead of silently elevating (OIDC Core §3.1.2.1):
Condition
Error code
A fresh authentication is required
login_required
Only re-consent is required
consent_required
Generic interaction fallback
interaction_required
A fresh-login requirement takes precedence over a pending consent — the user
cannot consent before re-authenticating.
For a client no operator provisioned (one registered through
POST /oauth/register, a Client ID Metadata Document or POST /api/clients),
these errors are shown on an issuer page with a link back to the client rather
than redirected automatically (RFC 9700 §4.11.2; see
Errors).
Every agent-attributable action is written to audit_logs with structured,
queryable columns on top of the existing event metadata. The agent-specific
columns are:
Column
What it records
user_id
The end user the agent acted on behalf of (the subject). Null for client_credentials (no subject user).
actor_client_id
The denormalized client_idstring of the agent that performed the action — stored as a string (not the FK) so the attribution survives deletion of the client row.
delegation_chain
The RFC 8693 act chain flattened to an ordered list of actor client_ids — index 0 is the outermost (most recent) actor, each following entry a prior actor. Null when not delegated.
scope_mode
The effective agent scope mode (readonly | admin | exec) the action ran under — the highest-ranked mode present in the granted scope set. An audit rollup only: it does not imply the lower modes were granted. Null when the action carries no agent-mode scope.
What each grant attributes:
Token Exchange — user_id = the subject user, actor_client_id = the
agent, delegation_chain = the flattened act chain, scope_mode = the
highest granted agent mode. Failures are audited too, with the delegation
depth.
client_credentials (agent acting as itself) — actor_client_id and
scope_mode are set when the client is a verified agent; user_id and
delegation_chain stay null (no subject user, no act chain). An ordinary
(non-agent) machine client records no agent fields — existing behavior is
unchanged.
No tokens or secrets are ever logged. Only public client identifiers reach
the audit table — the subject_token / actor_token, client secrets, and any
sensitive material are never persisted. The delegation_chain is constrained at
the database level to a JSON array of strings.
The full granted scope set (including every agent:* scope) is preserved
verbatim in the audit row’s metadata.scope; scope_mode is the queryable
rollup over that detail.