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:
- Agent client type (
is_agent) — marking a client as an agent, and why the flag is treated as untrusted. - Token Exchange (RFC 8693) —
exchanging a user's token for a delegated one carrying an
act(actor) claim. - Scope modes —
agent:readonly/agent:admin/agent:execreserved scopes bounded by an operator-set cap. - Step-up authentication — forcing fresh auth / re-consent before dangerous operations.
- Per-agent audit — what every delegated action records.
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.
An agent is an OAuth client whose oauth_clients.is_agent column is true.
There are three ways it is set, all of which write the same column:
| Path | How |
|---|---|
| Dynamic Client Registration (RFC 7591) | Send "is_agent": true in the POST /oauth/register body. Optional boolean, defaults to false; echoed back in the response as "is_agent": true. |
| Client ID Metadata Documents (CIMD) | Include "is_agent": true in the hosted metadata document the client_id URL resolves to. |
| Seed / operator provisioning | Set "is_agent": true on a client entry in the seed-oauth-clients manifest. |
Register an agent via DCR:
curl -s -X POST http://localhost:3000/oauth/register \
-H 'Content-Type: application/json' \
-d '{
"client_name": "My Agent",
"grant_types": ["urn:ietf:params:oauth:grant-type:token-exchange"],
"token_endpoint_auth_method": "client_secret_basic",
"is_agent": true
}' | jq
# → { "client_id": "…", "client_secret": "…", "is_agent": true, … }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_modecap 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).
curl -s -X POST http://localhost:3000/oauth/token \
-u "AGENT_CLIENT_ID:AGENT_CLIENT_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token=USERS_ACCESS_TOKEN \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d 'scope=read:docs' | jqResponse — issued_token_type is required by RFC 8693 §2.2.1:
| Parameter | Required | Notes |
|---|---|---|
grant_type |
yes | Exactly urn:ietf:params:oauth:grant-type:token-exchange. |
subject_token |
yes | The user's QAuth-issued access token (the party being acted for). |
subject_token_type |
yes | Must be urn:ietf:params:oauth:token-type:access_token. Any other type → invalid_request. |
actor_token |
no | The acting party's token. When present, actor_token_type is required (RFC 8693 §2.1). |
actor_token_type |
cond. | Required iff actor_token is sent; must be the access-token type, else invalid_request. |
requested_token_type |
no | If present, must be the access-token type; anything else → invalid_request. |
scope |
no | Space-separated; a subset of the subject token's scope. Omit to inherit the full set. |
resource / audience |
no | RFC 8707 / RFC 8693 targets; must fall within the subject token's aud. Omit to inherit. |
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's client_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 withinvalid_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 aclient_id. - Subject token must be bound to the agent. The
subject_tokenmust verify as a QAuth-issued access token — EdDSA signature +exp, QAuth's owniss, and an access-token use marker (ID tokens and other JWTs are rejected withinvalid_request) — and itsaudmust contain the requesting agent'sclient_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
scopemust be a subset of the subject token's scope (elseinvalid_scope);resource/audiencemust fall within the subject token'saud(elseinvalid_target). - Scope-mode cap still applies. Any reserved
agent:*scope that survives narrowing is additionally clamped to the agent's server-sidemax_agent_mode(see §3), so a capped agent cannot launder a higher mode through delegation even when the subject token carried it. - Lifetime never exceeds the subject token.
expires_inis clamped tomin(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).
For copy-paste curl and the full error table, see the
Token Exchange section of the OAuth 2.1 Flow guide.
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.
| Mode | Reserved scope | Meaning |
|---|---|---|
| ReadOnly | agent:readonly |
Read-only access |
| Admin | agent:admin |
Administrative; ⊇ ReadOnly |
| Exec | agent:exec |
Action-taking; the most privileged |
Cap ordering: ReadOnly ⊂ Admin ⊂ Exec. A client whose maximum mode is
admin may request agent:readonly and agent:admin, but not agent:exec.
The cap is a maximum, not an exact match.
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.
A reserved agent:* scope is granted only when both hold:
- the client is classified as an agent via the fail-closed
isAgentClientaccessor (isAgent === true, never missing/null), and - the requested mode is within the client's server-side
max_agent_modecap.
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 omits
is_agentis not an agent → noagent:*scope. - A client with no cap (
NULL) gets noagent:*scope — not even ReadOnly. - An unknown stored mode parses to
nulland denies. - A capped agent cannot obtain a higher mode (
admin-capped →agent:execrejected withinvalid_scope).
The cap is enforced on every path that can grant a reserved-mode scope, so the control holds wherever the code is actually issued:
| Grant / surface | Where |
|---|---|
/oauth/authorize |
findExceedingAgentScopesForClient → invalid_scope redirect (fast path, prior consent covers). |
/ui/consent |
The same gate on the POST (code issuance) and GET (render) — the common first-time path. |
client_credentials |
validateScopes(scope, client.scopes, toAgentScopeContext(client)) in the token endpoint. |
| Token Exchange (RFC 8693) | enforceAgentScopeCap against the narrowed scope set before the delegated token is minted. |
Provision an agent with a cap via the seed-oauth-clients manifest:
{
"realm": "master",
"clients": [
{
"client_id": "my-agent",
"name": "My Agent",
"grant_types": ["urn:ietf:params:oauth:grant-type:token-exchange"],
"scopes": ["agent:readonly", "agent:admin"],
"token_endpoint_auth_method": "client_secret_basic",
"is_agent": true, // self-asserted classification
"max_agent_mode": "admin", // operator-set ceiling — agent:exec stays denied
},
],
}DATABASE_URL="postgresql://qauth:${DB_PASSWORD}@localhost:5432/qauth" \
pnpm nx run infra-db:db:seed-oauth-clients -- --manifest=/path/to/manifest.jsonmcp-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 2025-11-25 incremental consent).
The classification is conservative and server-side only — never driven by a client signal:
- any
write:*scope (mutating access), and - the higher agent modes
agent:adminandagent:exec(both are action-taking).agent:readonlyis not dangerous.
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_agepresent and the session is older than it ⇒ fresh login (max_age=0demands a brand-new authentication; second-granularity flooring makes the post-login round-trip terminate instead of looping).
1. Agent calls the resource with a token lacking the needed (e.g. write/exec) scope.
→ mcp-guard returns 403 insufficient_scope + WWW-Authenticate scope hint.
2. Agent re-runs /oauth/authorize requesting the larger scope set.
→ the agent scope-mode cap is checked first; an over-cap agent:* scope
yields an invalid_scope redirect.
→ step-up evaluation decides fresh login / re-consent is needed.
3. User re-authenticates at /ui/login (and/or re-consents), creating a fresh session.
4. User returns to the original /oauth/authorize URL; the session is now fresh,
step-up is satisfied, and the authorization code is issued.
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.
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_id string 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 mode in the granted scope set. 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 flattenedactchain,scope_mode= the highest granted agent mode. Failures are audited too, with the delegation depth. client_credentials(agent acting as itself) —actor_client_idandscope_modeare set when the client is a verified agent;user_idanddelegation_chainstay null (no subject user, noactchain). 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.
- OAuth 2.1 Flow — the underlying endpoints with copy-paste
curl, including the Token Exchange section. - API Reference — endpoint contracts, including the
is_agentDCR field and the token-exchange grant. - MCP Quickstart — end-to-end QAuth → MCP handshake.
- ADR-007: MCP-First Positioning — why the agent-native layer is QAuth's differentiation.
@qauth-labs/mcp-guard— the resource-server SDK that emits the403 insufficient_scopestep-up challenge.
{ "access_token": "eyJ…", // sub = user, act = { "sub": "AGENT_CLIENT_ID" } "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "token_type": "Bearer", "expires_in": 900, "scope": "read:docs", }