Skip to content

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 (your JWT_ISSUER).
  • Tokens are EdDSA (Ed25519) signed JWTs; verify them against GET /.well-known/jwks.json.
  • PKCE is required and only S256 is supported.
  • Request bodies to /oauth/token and /oauth/introspect are application/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).


EndpointMethodPurpose
/.well-known/oauth-authorization-serverGETAS metadata (RFC 8414)
/.well-known/openid-configurationGETOIDC discovery (superset)
/.well-known/jwks.jsonGETPublic signing keys (RFC 7517)
/oauth/authorizeGETStart authorization_code + PKCE (browser)
/oauth/tokenPOSTExchange code / refresh / client credentials
/oauth/introspectPOSTToken introspection (RFC 7662)
/oauth/userinfoGETOIDC UserInfo (Bearer)
/oauth/registerPOSTDynamic Client Registration (RFC 7591, open)
/oauth/revokePOSTToken revocation (RFC 7009)

Discover these programmatically instead of hard-coding paths:

Terminal window
curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq

GrantSubject (sub)Refresh token?Use case
authorization_code (+ PKCE)the end userif consent is storedApps acting on behalf of a user
refresh_tokenthe end useryes (rotated)Renew an access token without re-prompting
client_credentialsthe client_idno (RFC 6749 §4.4.3)Machine-to-machine, no user
urn:ietf:params:oauth:grant-type:token-exchangethe end usernoAgent delegation on behalf of a user (RFC 8693)
urn:ietf:params:oauth:grant-type:jwt-bearerthe end usernoID-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.


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:

Terminal window
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)”
Terminal window
# 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.

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
ParameterRequiredNotes
response_typeyesMust be code.
client_idyesYour client. At most 2048 printable ASCII characters.
redirect_uriyesMust match a registered URI — see below.
code_challengeyesFrom step 1.
code_challenge_methodyesMust be S256.
scopenoSpace-separated; filtered to the client’s allowlist.
staterecommendedOpaque CSRF value; echoed back verbatim.
nonceOIDCBound into the ID token when issued.
resourcenoRFC 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 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 http scheme and a host of 127.0.0.0/8, [::1] or localhost. A native / CLI client can register http://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.
  • localhost is accepted because real clients (Claude Code among them) use it, but RFC 8252 §8.3 recommends the IP literal: prefer 127.0.0.1 or [::1] for clients you write.
  • https and 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_VALUE

Verify state matches what you sent before proceeding.

Terminal window
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-matching code_verifier are mandatory.
  • resource here must be a subset of the resource set bound at authorize time, or you get invalid_target. Omit it to inherit the code’s binding.
  • refresh_token is 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/authorize again once the access token expires. Requesting offline_access does not change this either way.
  • id_token is issued only for this grant (authorization_code), and only when the granted scope includes openid. It is a separate, client-audienced EdDSA JWT (aud = your client_id, not the resource aud the access token carries) asserting the sign-in event — see ID token claims below. client_credentials has no end user and never carries one; refresh_token does not reissue one either (see Refresh Token).

Beyond the standard iss / sub / aud / exp / iat, id_token carries (all via signIdToken, libs/server/jwt/src/lib/jwt-service.ts:170-204):

ClaimPresent when
nonceThe authorize request sent one (OIDC Core §3.1.3.6) — echoed back unmodified.
auth_timeAlways for the code flow (the session’s real authentication time, epoch seconds).
nameThe granted scope includes profile and the user has a firstName and/or lastName set.
emailThe granted scope includes email and a verified email attribute exists.
email_verifiedSame 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.

Terminal window
curl -s http://localhost:8088/mcp/memory \
-H "Authorization: Bearer ACCESS_TOKEN" | jq

A resource server (e.g. one using mcp-guard) verifies the signature against the JWKS and checks iss, exp, aud, and scope.


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.

Terminal window
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.
  • scope may be passed to down-scope only; requesting a scope not in the original set returns invalid_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 with invalid_scope and the refresh token is left unspent: send a narrower scope to continue.
  • resource may 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_token is issued on refresh, even when the original grant included openid — only the authorization_code grant mints one. If your client needs a fresh ID token, re-run the authorization flow.

No user, no browser. The token’s sub is the client_id and no refresh token is issued.

Terminal window
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_credentials grant and the requested scopes in its scopes allowlist. At least one scope is required (a scopeless machine token is rejected per RFC 9700).
  • resource must fall within the client’s configured audience (or defaults to the client_id); it sets the token aud.
  • Provision such clients with the seed script (it lets you set scopes and audience explicitly) — 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).

Terminal window
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' | jq

Response (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. Because is_agent is 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 with invalid_client.
  • Subject token must be bound to the agent. The subject_token must be a QAuth-issued access token (verified EdDSA signature + exp, matching issuer, and an access-use marker — ID tokens and other JWTs are rejected with invalid_request) and its aud must contain the requesting agent’s client_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 agent client_id. The subject user must also exist and be enabled.
  • Down-scoping only. scope must be a subset of the subject token’s scope (else invalid_scope); omit it to inherit the full set. resource / audience must fall within the subject token’s aud (else invalid_target). Scope and audience are preserved or narrowed — never widened.
  • Lifetime never exceeds the subject token. The delegated token’s expires_in is clamped to min(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_token is supported for subject_token_type / actor_token_type / requested_token_type; anything else returns invalid_request. An optional actor_token (the acting party) requires actor_token_type when present.
  • Bounded delegation depth. Chained re-exchanges are capped (the nested act chain may not exceed 4 actors); deeper requests get invalid_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.

Terminal window
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
ParameterRequiredNotes
client_assertion_typeyesExactly urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
client_assertionyesThe signed JWT. Its sub names the client.
client_idnoMAY 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) or jwks_uri (by reference). Key material carried by the assertion — jwk, jku, x5u headers — is rejected outright; honouring it would let anyone sign their own credential and supply the key to check it against.
  • Asymmetric algorithms only. alg is intersected with the same list discovery advertises, which contains no none and no HS*. 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 a private_key_jwt client cannot fall back to its secret.
  • aud is the issuer, and only the issuer. Name the issuer from the discovery document as the sole audience, as a string or a one-element array. The token endpoint URL is refused with invalid_client, and so is an aud array 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_jwt cannot be self-registered. POST /oauth/register accepts only none, client_secret_basic and client_secret_post, and the jwks / jwks_uri fields 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 — the seed-oauth-clients manifest or admin — exactly like max_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.

Terminal window
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… | jq

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 typ is oauth-id-jag+jwt, distinct from at+jwt (access token) and JWT (ID token), so no token signed for another purpose can be substituted — and vice versa;
  • aud is a single authorization-server issuer identifier. A multi-valued aud is rejected: an assertion authorizing two servers is one that either of them can redeem;
  • jti is consumed exactly once, inside a window bounded by ID_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) and resource (exactly one value, the MCP server) must both appear in the client’s audience list. An empty list denies.
  • The assertion’s scope is the subject token’s scope, or the narrower scope the request names, and every value must also appear in the client’s registered scopes. A value outside that list is refused with invalid_scope, not dropped: send scope to ask for less. A client registered with no scopes cannot mint a scoped assertion. This is the same check client_credentials applies (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.


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.

Terminal window
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)
FieldRequiredNotes
tokenYesThe access or refresh token to revoke.
token_type_hintNoaccess_token or refresh_token. Advisory only (§2.1) — the server determines the real type regardless.
client_idNoOnly when authenticating via client_secret_post.
client_secretNoOnly 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.

Every way of ending a grant stops new tokens being minted from it:

RevocationEnds
/oauth/revoke with a refresh token, or a replayed oneThat token’s whole rotation family
/oauth/revoke with an access tokenThat 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/logoutThe 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.


Resource servers can validate opaque or near-real-time-revocable tokens by asking the AS. Requires confidential client authentication.

Terminal window
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 carries pqc_signature and pqc_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 the active decision.


Return the authenticated end user’s claims for a user-context access token:

Terminal window
curl -s http://localhost:3000/oauth/userinfo \
-H "Authorization: Bearer ACCESS_TOKEN" | jq
# → { "sub": "…", "email": "…", "email_verified": true }

email/email_verified require the email scope AND a verified email attribute (ADR-002 trust order). With no verified email on record, both claims are omitted entirely — treat email as optional. When present, email_verified is always true.

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 no resource, for a client without an operator-configured audience, 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/userinfo here).

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.


POST /oauth/register is open (no initial_access_token) and rate-limited. See the example in step 0. Key fields:

FieldNotes
redirect_urisRequired for authorization_code. Schemes: see below.
grant_typesSubset of authorization_code, refresh_token, client_credentials.
token_endpoint_auth_methodnone (public/PKCE), client_secret_basic, or client_secret_post.
scopeSpace-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.


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. error carries the bare RFC 6749 §5.2 token and error_description carries the human-readable detail, where there is any to give.

/oauth/authorize is different by design. Once client_id and redirect_uri validate it returns invalid_scope, access_denied, login_required and consent_required as redirect query parameters built in the route itself. The errors RFC 6749 §4.1.2.1 forbids redirecting — an unknown client and a redirect_uri that is not registered or not permitted for the environment — are thrown as BadRequestError and reach the caller in the JSON body instead. So is unauthorized_client (a disabled client, or one not registered for the authorization_code grant or the code response 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-clients manifest (oauth_clients.provisioned_by_operator). Every other client registered its own redirect URI: one created through POST /oauth/register, one resolved from a Client ID Metadata Document, and one a developer account created through POST /api/clients. For such a client an error that would leave the issuer before any QAuth page is shown (for example login_required for an unauthenticated prompt=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 through PATCH /api/clients/{id} removes that trust.

CodeMeaning
invalid_requestMissing/malformed parameter.
invalid_clientClient authentication failed or client unknown.
invalid_grantBad/expired code, bad PKCE verifier, or invalid/replayed refresh token.
unauthorized_clientClient not allowed to use this grant.
invalid_scopeRequested scope outside the allowlist (or empty for client_credentials).
invalid_targetRFC 8707 resource outside the grant’s bound audience.
unsupported_grant_typeUnknown grant_type.