OAuth 2.1 Flow
This page documents QAuth’s OAuth 2.1 / OIDC endpoints with copy-paste curl
for every step, so you can implement a client by hand. If your goal is to wire
QAuth to an MCP server, start with the MCP Quickstart —
this page is the lower-level reference it builds on.
Conventions used below
- Base URL / issuer:
http://localhost:3000(yourJWT_ISSUER). - Tokens are EdDSA (Ed25519) signed JWTs; verify them against
GET /.well-known/jwks.json. - PKCE is required and only
S256is supported. - Request bodies to
/oauth/tokenand/oauth/introspectareapplication/x-www-form-urlencoded(RFC 6749 §3.2, RFC 7662 §2.1).
Standards: RFC 6749 (OAuth 2.0) · OAuth 2.1 draft · RFC 7636 (PKCE) · RFC 8707 (Resource Indicators) · RFC 7662 (Introspection) · RFC 8414 (AS Metadata) · OIDC Core / Discovery 1.0 · RFC 9700 (OAuth 2.0 Security BCP).
Endpoints at a glance
Section titled “Endpoints at a glance”| Endpoint | Method | Purpose |
|---|---|---|
/.well-known/oauth-authorization-server | GET | AS metadata (RFC 8414) |
/.well-known/openid-configuration | GET | OIDC discovery (superset) |
/.well-known/jwks.json | GET | Public signing keys (RFC 7517) |
/oauth/authorize | GET | Start authorization_code + PKCE (browser) |
/oauth/token | POST | Exchange code / refresh / client credentials |
/oauth/introspect | POST | Token introspection (RFC 7662) |
/oauth/userinfo | GET | OIDC UserInfo (Bearer) |
/oauth/register | POST | Dynamic Client Registration (RFC 7591, open) |
/oauth/revoke | POST | Token revocation (RFC 7009) |
Discover these programmatically instead of hard-coding paths:
curl -s http://localhost:3000/.well-known/oauth-authorization-server | jqGrant types
Section titled “Grant types”| Grant | Subject (sub) | Refresh token? | Use case |
|---|---|---|---|
authorization_code (+ PKCE) | the end user | if consent is stored | Apps acting on behalf of a user |
refresh_token | the end user | yes (rotated) | Renew an access token without re-prompting |
client_credentials | the client_id | no (RFC 6749 §4.4.3) | Machine-to-machine, no user |
urn:ietf:params:oauth:grant-type:token-exchange | the end user | no | Agent delegation on behalf of a user (RFC 8693) |
urn:ietf:params:oauth:grant-type:jwt-bearer | the end user | no | ID-JAG — enterprise-managed authorization (ADR-011) |
response_type is code only. There is no implicit or password grant
(removed in OAuth 2.1).
The jwt-bearer grant is off by default (ID_JAG_ENABLED=false) and is
advertised in grant_types_supported only while it is on and
ID_JAG_TRUSTED_ISSUERS is not empty. With the flag off the token endpoint
answers unsupported_grant_type, and with an empty allowlist it rejects every
assertion, so advertising the grant would be a false capability claim. See ID-JAG.
Authorization Code + PKCE (user context)
Section titled “Authorization Code + PKCE (user context)”0. Prerequisites — a client
Section titled “0. Prerequisites — a client”You need a registered client with the authorization_code grant, a registered
redirect_uri, and the scopes you want in its allowlist. A public client
(SPA / native / CLI) uses token_endpoint_auth_method: none and authenticates
purely with PKCE — no secret. Register one with
Dynamic Client Registration:
curl -s -X POST http://localhost:3000/oauth/register \ -H 'Content-Type: application/json' \ -d '{ "client_name": "My App", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "redirect_uris": ["http://localhost:5173/callback"], "token_endpoint_auth_method": "none" }' | jq# → { "client_id": "…", "token_endpoint_auth_method": "none", … }Scopes are deny-by-default. The authorize endpoint only grants scopes that are in the client’s allowlist. DCR-registered clients are capped to the realm allowlist (
DEFAULT_DYNAMIC_REGISTRATION_SCOPES); set that env var to include any non-OIDC scopes (e.g.mcp:read) you intend to request.
1. Generate a PKCE verifier and challenge (RFC 7636)
Section titled “1. Generate a PKCE verifier and challenge (RFC 7636)”# code_verifier: 43–128 chars from [A-Za-z0-9._~-]code_verifier=$(openssl rand -base64 96 | tr -d '\n=+/' | cut -c1-64)
# code_challenge = BASE64URL(SHA256(code_verifier))code_challenge=$(printf '%s' "$code_verifier" \ | openssl dgst -binary -sha256 \ | openssl base64 | tr '+/' '-_' | tr -d '=\n')
echo "verifier=$code_verifier"echo "challenge=$code_challenge"Keep code_verifier secret and in memory; you’ll send it at the token step.
2. Redirect the user to /oauth/authorize
Section titled “2. Redirect the user to /oauth/authorize”Open this URL in a browser (the user authenticates and consents at QAuth):
http://localhost:3000/oauth/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=http://localhost:5173/callback &code_challenge=CODE_CHALLENGE &code_challenge_method=S256 &scope=openid%20profile%20email &state=RANDOM_OPAQUE_VALUE &resource=http://localhost:8088| Parameter | Required | Notes |
|---|---|---|
response_type | yes | Must be code. |
client_id | yes | Your client. At most 2048 printable ASCII characters. |
redirect_uri | yes | Must match a registered URI — see below. |
code_challenge | yes | From step 1. |
code_challenge_method | yes | Must be S256. |
scope | no | Space-separated; filtered to the client’s allowlist. |
state | recommended | Opaque CSRF value; echoed back verbatim. |
nonce | OIDC | Bound into the ID token when issued. |
resource | no | RFC 8707 target(s); binds the token aud. Repeat for multiple, up to 10. |
redirect_uri and each resource are absolute URIs of at most 2048
characters, written in ASCII as RFC 3986 requires: an internationalised host in
its xn-- form, any other non-ASCII character percent-encoded. A raw Unicode
character or a space is refused with 400.
Redirect URI matching
Section titled “Redirect URI matching”redirect_uri is compared with the client’s registered set (or, for a
CIMD client, the document’s redirect_uris) by
exact string — no wildcards, no prefix matching, no normalisation
(RFC 9700 §2.1). There is one exception, required by RFC 8252 §7.3 for native
apps: a loopback redirect may carry any port.
- Loopback means the
httpscheme and a host of127.0.0.0/8,[::1]orlocalhost. A native / CLI client can registerhttp://127.0.0.1/callback(no port) and call back on whatever port the OS gave its listener, e.g.redirect_uri=http://127.0.0.1:53817/callback. - Only the port may differ. Scheme, host, path and query must still match
byte for byte, and the host literals are not interchangeable
(
localhost≠127.0.0.1≠[::1]) — register each one you use. localhostis accepted because real clients (Claude Code among them) use it, but RFC 8252 §8.3 recommends the IP literal: prefer127.0.0.1or[::1]for clients you write.httpsand private-use-scheme redirects always need an exact match, port included.- QAuth redirects to the port you requested and binds the code to that exact URI, so the token request must repeat it verbatim — port included (RFC 6749 §4.1.3).
- PKCE (mandatory for every client) is what keeps another listener on the same host from redeeming an intercepted code (RFC 8252 §8.1).
QAuth flow: if there’s no active session it shows the login page; then a
consent screen for the requested scopes, naming each requested resource
as the audience (skipped if a prior consent already covers those scopes for
every requested resource). See Hosted UI for what
those screens look like and the pending-authorization mechanics behind the
login bounce. On approval it redirects:
http://localhost:5173/callback?code=AUTH_CODE&state=RANDOM_OPAQUE_VALUEVerify state matches what you sent before proceeding.
3. Exchange the code for tokens
Section titled “3. Exchange the code for tokens”curl -s -X POST http://localhost:3000/oauth/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d grant_type=authorization_code \ -d code=AUTH_CODE \ -d redirect_uri=http://localhost:5173/callback \ -d client_id=YOUR_CLIENT_ID \ -d code_verifier=$code_verifier \ -d resource=http://localhost:8088 | jq{ "access_token": "eyJ…", "refresh_token": "a1b2…(64 hex)", "id_token": "eyJ…", // present because the request above granted `openid` "expires_in": 900, "token_type": "Bearer", "scope": "openid profile email"}id_token is present only when the granted scope includes openid on the
authorization_code path (OIDC Core §3.1.3.3) — as in this example. Drop openid
from scope and the member is absent entirely.
Notes:
- Confidential clients additionally authenticate, either with HTTP Basic
(
-u "CLIENT_ID:CLIENT_SECRET",client_secret_basic) or by adding-d client_secret=…(client_secret_post). Public clients send neither. - The authorization code is single-use and short-lived; the same
redirect_uri(exactly as sent to/oauth/authorize, loopback port included) and a PKCE-matchingcode_verifierare mandatory. resourcehere must be a subset of the resource set bound at authorize time, or you getinvalid_target. Omit it to inherit the code’s binding.refresh_tokenis present only when the user has a stored consent for your client that covers the granted scopes — the row they can review and revoke under/consents. If they untick Remember this decision on the consent screen, the approval is one-time: the member is absent, and you send them through/oauth/authorizeagain once the access token expires. Requestingoffline_accessdoes not change this either way.id_tokenis issued only for this grant (authorization_code), and only when the granted scope includesopenid. It is a separate, client-audienced EdDSA JWT (aud= yourclient_id, not the resourceaudthe access token carries) asserting the sign-in event — see ID token claims below.client_credentialshas no end user and never carries one;refresh_tokendoes not reissue one either (see Refresh Token).
ID token claims (OIDC)
Section titled “ID token claims (OIDC)”Beyond the standard iss / sub / aud / exp / iat, id_token carries
(all via signIdToken, libs/server/jwt/src/lib/jwt-service.ts:170-204):
| Claim | Present when |
|---|---|
nonce | The authorize request sent one (OIDC Core §3.1.3.6) — echoed back unmodified. |
auth_time | Always for the code flow (the session’s real authentication time, epoch seconds). |
name | The granted scope includes profile and the user has a firstName and/or lastName set. |
email | The granted scope includes email and a verified email attribute exists. |
email_verified | Same condition as email; always true when present. |
email/email_verified share the same trust-ordered resolution the access
token and UserInfo use, so all three never disagree within
one issuance. name follows the rule UserInfo applies (OIDC Core §5.4), so a
client that asks only for openid gets no name from either.
The access token follows the same email rule on every grant
(authorization_code, refresh_token, token exchange, jwt-bearer): it
carries email/email_verified only when its own scope includes email
and a verified address exists. A resource server that needs the address has
the client request email, which puts it on the consent screen. A refresh or
an exchange that narrows email out of the scope drops the claims, and
client_credentials and the first-party /auth/login token never carry them.
4. Call a protected resource
Section titled “4. Call a protected resource”curl -s http://localhost:8088/mcp/memory \ -H "Authorization: Bearer ACCESS_TOKEN" | jqA resource server (e.g. one using mcp-guard)
verifies the signature against the JWKS and checks iss, exp, aud, and scope.
Refresh Token (rotation)
Section titled “Refresh Token (rotation)”Renew an access token without re-prompting the user. QAuth rotates the
refresh token on every use and detects replay (RFC 9700 §2.2.2): reusing a
already-rotated token revokes the entire token family. That holds for as long
as the family has a live token: the hourly purge-expired job deletes a family
only once its newest token has expired, rotated tokens included.
curl -s -X POST http://localhost:3000/oauth/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d grant_type=refresh_token \ -d refresh_token=CURRENT_REFRESH_TOKEN \ -d client_id=YOUR_CLIENT_ID | jq- The response contains a new
refresh_token— store it and discard the old. scopemay be passed to down-scope only; requesting a scope not in the original set returnsinvalid_scope. Omit it to keep the original scopes.- The refreshed scopes must still be allowed for the client. If a scope was
removed from the client (or above its
max_agent_mode) since the grant, the refresh fails withinvalid_scopeand the refresh token is left unspent: send a narrowerscopeto continue. resourcemay narrow the audience but never widen it beyond the set bound to the refresh token.- Confidential clients authenticate as in step 3; public clients send only
client_id(ownership is enforced by refresh-token binding). - No
id_tokenis issued on refresh, even when the original grant includedopenid— only theauthorization_codegrant mints one. If your client needs a fresh ID token, re-run the authorization flow.
Client Credentials (machine-to-machine)
Section titled “Client Credentials (machine-to-machine)”No user, no browser. The token’s sub is the client_id and no refresh token
is issued.
curl -s -X POST http://localhost:3000/oauth/token \ -u "CLIENT_ID:CLIENT_SECRET" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d grant_type=client_credentials \ -d scope=mcp:read \ -d resource=http://localhost:8088 | jq- The client must have the
client_credentialsgrant and the requested scopes in itsscopesallowlist. At least one scope is required (a scopeless machine token is rejected per RFC 9700). resourcemust fall within the client’s configuredaudience(or defaults to theclient_id); it sets the tokenaud.- Provision such clients with the seed script (it lets you set
scopesandaudienceexplicitly) — see the MCP Quickstart, Option B.
Token Exchange — agent on-behalf-of delegation (RFC 8693)
Section titled “Token Exchange — agent on-behalf-of delegation (RFC 8693)”ADR-007 §2 / agent-native authorization. On-behalf-of delegation is an MCP auth extension (ext-auth), not core MCP — QAuth provides it as a value-add. This section is the wire-level reference; for the end-to-end agent story (registering an agent, scope modes, step-up, and audit) see the Agent Authorization guide.
An agent client exchanges a user’s access token (subject_token) for a
delegated access token whose sub is 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):
{ "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",}Rules and guarantees:
- Agent-only, default-deny. Only clients classified as agents
(
is_agent: true) and granted the token-exchange grant type may use it. Becauseis_agentis self-asserted, the server never trusts it alone. - Confidential clients only. The token-exchange grant requires confidential
client authentication (
client_secret_basic/client_secret_post); a public agent (token_endpoint_auth_method=none) is rejected withinvalid_client. - Subject token must be bound to the agent. The
subject_tokenmust be a QAuth-issued access token (verified EdDSA signature +exp, matching issuer, and anaccess-use marker — ID tokens and other JWTs are rejected withinvalid_request) and itsaudmust contain the requesting agent’sclient_id— i.e. the token was minted for this agent. Together with the confidential-client requirement, this prevents an attacker from minting a delegated token from any captured user token plus a known agentclient_id. The subject user must also exist and be enabled. - Down-scoping only.
scopemust be a subset of the subject token’s scope (elseinvalid_scope); omit it to inherit the full set.resource/audiencemust fall within the subject token’saud(elseinvalid_target). Scope and audience are preserved or narrowed — never widened. - Lifetime never exceeds the subject token. The delegated token’s
expires_inis clamped tomin(configured_lifespan, subject_token_remaining), so delegation can never outlast the authority it derives from. - Token types. Only
urn:ietf:params:oauth:token-type:access_tokenis supported forsubject_token_type/actor_token_type/requested_token_type; anything else returnsinvalid_request. An optionalactor_token(the acting party) requiresactor_token_typewhen present. - Bounded delegation depth. Chained re-exchanges are capped (the nested
actchain may not exceed 4 actors); deeper requests getinvalid_request. - No refresh token is issued — a delegated token is short-lived; the agent re-exchanges as needed.
- Every exchange (success and failure) is written to
audit_logs, including the actor and delegation depth.
Client authentication with private_key_jwt (RFC 7523 §2.2)
Section titled “Client authentication with private_key_jwt (RFC 7523 §2.2)”A confidential client can authenticate at the token endpoint by presenting a
short-lived JWT it signed, instead of a shared secret. QAuth advertises
private_key_jwt in token_endpoint_auth_methods_supported unconditionally.
The practical motivation is CIMD: a client identified by an HTTPS URL was never issued a secret, so assertion-based authentication is the only confidential method available to it.
curl -s -X POST http://localhost:3000/oauth/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d grant_type=client_credentials \ -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ -d client_assertion=eyJ… | jq| Parameter | Required | Notes |
|---|---|---|
client_assertion_type | yes | Exactly urn:ietf:params:oauth:client-assertion-type:jwt-bearer. |
client_assertion | yes | The signed JWT. Its sub names the client. |
client_id | no | MAY be omitted when an assertion is present (RFC 7521 §4.2) — the assertion’s sub names it. |
Rules that are load-bearing rather than incidental:
- The public key comes from registration, never from the assertion. Keys are
read from the client’s registered
jwks(inline) orjwks_uri(by reference). Key material carried by the assertion —jwk,jku,x5uheaders — is rejected outright; honouring it would let anyone sign their own credential and supply the key to check it against. - Asymmetric algorithms only.
algis intersected with the same list discovery advertises, which contains nononeand noHS*. An HS256 assertion verified against a public JWK is the classic algorithm-confusion attack, where the “signature” is an HMAC over a key the attacker can also read. - The registered method must match exactly. A client provisioned for
client_secret_*cannot authenticate by assertion, and aprivate_key_jwtclient cannot fall back to its secret. audis the issuer, and only the issuer. Name theissuerfrom the discovery document as the sole audience, as a string or a one-element array. The token endpoint URL is refused withinvalid_client, and so is anaudarray that holds anything next to the issuer (draft-ietf-oauth-rfc7523bis§4). Many client libraries default to the token endpoint URL, so set the audience explicitly.- One method per request. Presenting more than one authentication method is
rejected with
invalid_client(RFC 6749 §2.3) — never “try each until one passes”.
⚠️
private_key_jwtcannot be self-registered.POST /oauth/registeraccepts onlynone,client_secret_basicandclient_secret_post, and thejwks/jwks_urifields are stripped from a registration request. This is deliberate: a client must not be able to self-register the keys that authenticate it, nor hand the server a URL to dereference, through an unauthenticated endpoint. It is provisioned by an operator — theseed-oauth-clientsmanifest or admin — exactly likemax_agent_mode.
ID-JAG — enterprise-managed authorization (ADR-011)
Section titled “ID-JAG — enterprise-managed authorization (ADR-011)”An Identity Assertion JWT Authorization Grant is the credential at the centre of MCP Enterprise-Managed Authorization. QAuth implements both sides.
Off by default. ID_JAG_ENABLED=false, and ID_JAG_TRUSTED_ISSUERS defaults
to empty — an empty allowlist rejects every assertion. Nothing in an assertion
ever nominates its own trust: verification keys come only from an OIDC discovery
run against an already-allowlisted issuer.
Consuming an ID-JAG (QAuth as the resource authorization server)
Section titled “Consuming an ID-JAG (QAuth as the resource authorization server)”A client presents an ID-JAG minted by a trusted enterprise IdP under the
jwt-bearer grant and receives an access token audience-restricted to the MCP
server named by the assertion’s resource claim.
curl -s -X POST http://localhost:3000/oauth/token \ -u "CLIENT_ID:CLIENT_SECRET" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \ -d assertion=eyJ… | jqAn ID-JAG is not an access token
Section titled “An ID-JAG is not an access token”It is a single-use, short-lived, audience-restricted authorization grant, presented to one authorization server exactly once and exchanged. Three properties enforce that:
- the protected header
typisoauth-id-jag+jwt, distinct fromat+jwt(access token) andJWT(ID token), so no token signed for another purpose can be substituted — and vice versa; audis a single authorization-server issuer identifier. A multi-valuedaudis rejected: an assertion authorizing two servers is one that either of them can redeem;jtiis consumed exactly once, inside a window bounded byID_JAG_MAX_ASSERTION_LIFETIME.
The grant is confidential-client only, and the client must additionally be
registered for it — which, like private_key_jwt, only an operator can do.
An assertion carrying authorization_details (RFC 9396) is refused, not
ignored. QAuth does not implement rich authorization requests on this path, and
silently dropping a constraint the enterprise IdP applied would hand the client
more authority than was authorized — a downgrade. Unrecognised members that are
not authorization constraints are still tolerated, so a later spec revision
does not break existing deployments.
Every rejection returns a bare invalid_grant (RFC 6749 §5.2). The specific
reason is written to the audit log and never to the wire: a caller learning which
check failed would learn whether an issuer is allowlisted, whether a jti was
already burned, and whether a kid exists — all oracles.
Minting an ID-JAG (QAuth as the enterprise IdP)
Section titled “Minting an ID-JAG (QAuth as the enterprise IdP)”An RFC 8693 token exchange requesting
requested_token_type=urn:ietf:params:oauth:token-type:id-jag returns an
assertion targeted at a third-party resource authorization server.
The agent client is provisioned by the operator, and its registration bounds what it can mint:
audience(exactly one value, the target authorization server’s issuer) andresource(exactly one value, the MCP server) must both appear in the client’saudiencelist. An empty list denies.- The assertion’s
scopeis the subject token’s scope, or the narrowerscopethe request names, and every value must also appear in the client’s registeredscopes. A value outside that list is refused withinvalid_scope, not dropped: sendscopeto ask for less. A client registered with noscopescannot mint a scoped assertion. This is the same checkclient_credentialsapplies (ADR-011 §3, amended 2026-10-19).
Minted assertions are signed with EdDSA — the same key that signs access tokens —
so a foreign authorization server verifies them from the JWKS it already fetches
from GET /.well-known/jwks.json. This is deliberately not the hybrid
(ADR-005) signer: the detached ML-DSA component is delivered through
introspection, and a foreign server has no introspection relationship with QAuth,
so a hybrid ID-JAG would be unverifiable at exactly the party that must verify it.
Token Revocation (RFC 7009)
Section titled “Token Revocation (RFC 7009)”POST /oauth/revoke invalidates an access or refresh token. Requires
confidential client authentication — client_secret_basic (header) or
client_secret_post (body); the route rejects a request using neither.
curl -s -X POST http://localhost:3000/oauth/revoke \ -u "CLIENT_ID:CLIENT_SECRET" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d token=REFRESH_TOKEN \ -d token_type_hint=refresh_token -i# → HTTP/1.1 200 OK (empty body)| Field | Required | Notes |
|---|---|---|
token | Yes | The access or refresh token to revoke. |
token_type_hint | No | access_token or refresh_token. Advisory only (§2.1) — the server determines the real type regardless. |
client_id | No | Only when authenticating via client_secret_post. |
client_secret | No | Only when authenticating via client_secret_post. |
It always returns 200 with an empty body on success — including when the
token was already expired, already revoked, or simply never existed (RFC 7009
§2.2). That is deliberate: a distinguishable response would let a caller probe
which tokens are valid. The only non-200 outcome is invalid_client (§2.2.1)
when client authentication fails.
Revoking a refresh token also revokes its whole rotation family, so a stolen descendant cannot be replayed.
What revocation reaches
Section titled “What revocation reaches”Every way of ending a grant stops new tokens being minted from it:
| Revocation | Ends |
|---|---|
/oauth/revoke with a refresh token, or a replayed one | That token’s whole rotation family |
/oauth/revoke with an access token | That access token (denylisted by jti until it would have expired) |
Consent revocation (DELETE /consents/{id}, /api/consents) | The user’s refresh tokens and unredeemed authorization codes for that client |
POST /auth/logout | The user’s hosted browser sessions, refresh tokens and unredeemed authorization codes at every client, and the access token presented |
Access tokens already issued under the grant are not reached. They are
stateless JWTs and stay valid until exp (ACCESS_TOKEN_LIFESPAN, 900 s by
default) wherever they are accepted: at resource servers that verify them
locally, at introspection, at /oauth/userinfo, and as a token-exchange
subject_token. Only an access token revoked by value, at /oauth/revoke or
as the token presented to /auth/logout, is refused before then. A short
access-token lifetime is what bounds this window. A delegated token from token
exchange, and an ID-JAG minted from one, never outlive the subject token they
were derived from.
Token Introspection (RFC 7662)
Section titled “Token Introspection (RFC 7662)”Resource servers can validate opaque or near-real-time-revocable tokens by asking the AS. Requires confidential client authentication.
curl -s -X POST http://localhost:3000/oauth/introspect \ -u "CLIENT_ID:CLIENT_SECRET" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d token=ACCESS_TOKEN | jq{ "active": true, "sub": "…", "client_id": "…", "scope": "openid profile email", "aud": "http://localhost:8088", "iss": "http://localhost:3000", "exp": 1750000000, "iat": 1749999100, "token_type": "Bearer"}An inactive, expired, unknown, or wrong-audience token, or a user’s token once
that user is disabled, returns { "active": false } with no other fields. For most resource servers, local
JWT verification against the JWKS is preferred (no per-request round-trip);
use introspection when you need immediate revocation.
When
HYBRID_SIGNING_ENABLED=true(default off — see the Status page), a successful response also carriespqc_signatureandpqc_alg: the token’s detached ML-DSA-65 signature and algorithm, delivered here because the bearer JWT itself has no room for a second signature. Omitted whenever the flag is off or the signature record isn’t found; neither case affects theactivedecision.
UserInfo (OIDC)
Section titled “UserInfo (OIDC)”Return the authenticated end user’s claims for a user-context access token:
curl -s http://localhost:3000/oauth/userinfo \ -H "Authorization: Bearer ACCESS_TOKEN" | jq# → { "sub": "…", "email": "…", "email_verified": true }
email_verifiedrequire theemail_verifiedis alwaystrue.
UserInfo accepts an access token only when it is addressed to UserInfo. Its
aud must contain either:
- the token’s own
client_id. A grant that named noresource, for a client without an operator-configuredaudience, gets exactly that, so the token from an ordinary OIDC code flow works; or - the UserInfo URL that discovery publishes as
userinfo_endpoint(http://localhost:3000/oauth/userinfohere).
A token whose aud names only other resource servers is refused with 401
and WWW-Authenticate: Bearer error="invalid_token". The token from the code
flow above, bound by resource=http://localhost:8088, is such a token: it is
for that MCP server, and that server must not be able to read the user’s
claims with it. A client that binds its grant to a resource server can read
the user’s identity from the ID token instead, or list
the UserInfo URL as another resource at /oauth/authorize. The second gives
a token both audiences accept, so the resource server can then call UserInfo
with it too.
Dynamic Client Registration (RFC 7591)
Section titled “Dynamic Client Registration (RFC 7591)”POST /oauth/register is open (no initial_access_token) and rate-limited.
See the example in step 0. Key fields:
| Field | Notes |
|---|---|
redirect_uris | Required for authorization_code. Schemes: see below. |
grant_types | Subset of authorization_code, refresh_token, client_credentials. |
token_endpoint_auth_method | none (public/PKCE), client_secret_basic, or client_secret_post. |
scope | Space-separated; capped to the realm allowlist. |
Each redirect URI must be https, http to a loopback host (127.0.0.1,
[::1] or localhost), or an RFC 8252 §7.1 private-use scheme for a native
app: a reverse domain name you control, such as com.example.app:/callback. Any
other scheme, javascript:, data:, file: and schemes without a period
(myapp:) included, is refused with invalid_redirect_uri, and a URI with a
fragment is refused too. The same rule applies to POST /api/clients and to a
CIMD document, and /oauth/authorize refuses any other scheme still stored on a
client.
Those two lists are exhaustive for self-registration, and the omissions are
deliberate rather than incomplete. private_key_jwt, the jwks / jwks_uri
fields, and the jwt-bearer (ID-JAG) grant are all operator-provisioned
only — a client must not be able to grant itself a capability whose trust
boundary an operator owns. Zod strips these keys, so a registration request
carrying them is silently ignored rather than honoured; do not rely on that
silence as the enforcement mechanism.
For MCP clients, CIMD (an HTTPS-URL client_id) is the recommended
alternative to DCR — see the MCP Quickstart.
Errors
Section titled “Errors”QAuth returns standard OAuth error codes (RFC 6749 §5.2):
Where they arrive. In a JSON error body these codes come from
apps/auth-server/src/app/plugins/error-handler.ts, which is registered ahead of both route sweeps so that every route resolves it.errorcarries the bare RFC 6749 §5.2 token anderror_descriptioncarries the human-readable detail, where there is any to give.
/oauth/authorizeis different by design. Onceclient_idandredirect_urivalidate it returnsinvalid_scope,access_denied,login_requiredandconsent_requiredas redirect query parameters built in the route itself. The errors RFC 6749 §4.1.2.1 forbids redirecting — an unknown client and aredirect_urithat is not registered or not permitted for the environment — are thrown asBadRequestErrorand reach the caller in the JSON body instead. So isunauthorized_client(a disabled client, or one not registered for theauthorization_codegrant or thecoderesponse type): it is checked before any user interaction, and is never redirected. See the error model.Clients no operator provisioned. RFC 9700 §4.11.2 lets an authorization server redirect automatically only to a redirect URI it trusts. QAuth trusts the redirect URIs of a client an operator provisioned: the system client and the clients of the
db:seed-oauth-clientsmanifest (oauth_clients.provisioned_by_operator). Every other client registered its own redirect URI: one created throughPOST /oauth/register, one resolved from a Client ID Metadata Document, and one a developer account created throughPOST /api/clients. For such a client an error that would leave the issuer before any QAuth page is shown (for examplelogin_requiredfor an unauthenticatedprompt=none) is rendered on an issuer page, with HTTP 400 and a link that carries the full error response to the redirect URI, instead of a 302. An owner who changes the redirect URIs of an operator-provisioned client throughPATCH /api/clients/{id}removes that trust.
| Code | Meaning |
|---|---|
invalid_request | Missing/malformed parameter. |
invalid_client | Client authentication failed or client unknown. |
invalid_grant | Bad/expired code, bad PKCE verifier, or invalid/replayed refresh token. |
unauthorized_client | Client not allowed to use this grant. |
invalid_scope | Requested scope outside the allowlist (or empty for client_credentials). |
invalid_target | RFC 8707 resource outside the grant’s bound audience. |
unsupported_grant_type | Unknown grant_type. |
See also
Section titled “See also”- MCP Quickstart — end-to-end QAuth → MCP handshake.
- Hosted UI — the login, consent, and resume screens.
- Agent Authorization — the agent-native layer
(
is_agent, scope modes, step-up, per-agent audit) built on these grants. @qauth-labs/mcp-guard— resource-server SDK that validates these tokens.- ADR-006: OAuth grants and audience.