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 | yes | 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 — with the flag off
the token endpoint answers unsupported_grant_type, so advertising it 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. |
redirect_uri | yes | Must exactly match a registered URI. |
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. |
QAuth flow: if there’s no active session it shows the login page; then a consent screen for the requested scopes (skipped if a prior consent already covers them). 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_uriand 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.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 user has a firstName and/or lastName set — not gated by the profile scope. |
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.
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.
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.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. - 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.
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.
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 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.
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. |
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. |
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 returnsunauthorized_client,invalid_scope,access_deniedandlogin_requiredas redirect query parameters built in the route itself (apps/auth-server/src/app/routes/oauth/authorize.ts:230,:393). The errors RFC 6749 §4.1.2.1 forbids redirecting — an unknown client (apps/auth-server/src/app/routes/oauth/authorize.ts:167) and aredirect_urithat is not registered or not permitted for the environment (:185,:214) — are thrown asBadRequestErrorand reach the caller in the JSON body instead. See the error model.
| 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.