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 useryesApps 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 — with the flag off the token endpoint answers unsupported_grant_type, so advertising it 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.
redirect_uriyesMust exactly match a registered URI.
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.

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_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 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.
  • 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 user has a firstName and/or lastName set — not gated by the profile scope.
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.

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.

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.
  • 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.
  • 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.

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.


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 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.


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.
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.

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 unauthorized_client, invalid_scope, access_denied and login_required as 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 a redirect_uri that is not registered or not permitted for the environment (:185, :214) — are thrown as BadRequestError and reach the caller in the JSON body instead. See the error model.

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.