# QAuth documentation, full text > QAuth is an open-source (Apache 2.0), self-hosted OAuth 2.1 and OpenID Connect authorization server for apps, services and AI agents. Agents act for a person through RFC 8693 token exchange with capped scope modes, step-up before dangerous operations and a per-agent audit trail; MCP servers get the MCP authorization profile (revision 2026-07-28), with @qauth-labs/resource-guard on the resource-server side; tokens can carry a hybrid ML-DSA-65 + Ed25519 signature behind a default-off switch. QAuth is ready for production; it stays below 1.0 only because features planned for 1.0 are not finished. # QAuth URL: https://docs.qauth.dev/ QAuth is an open-source, self-hostable OAuth 2.1 / OIDC authorization server whose near-term focus is authorization for MCP servers and AI agents. --- # Integrate URL: https://docs.qauth.dev/integrate/ This lane is for developers adding QAuth-issued tokens to an application or MCP server. --- # MCP Quickstart URL: https://docs.qauth.dev/integrate/mcp-quickstart/ This guide takes you from nothing to a **working MCP authorization handshake** on your machine: 1. Run **QAuth** — the OAuth 2.1 authorization server (AS). 2. Run a **guarded MCP resource server** — the bundled `memory-mcp` example, protected by [`@qauth-labs/resource-guard`](https://github.com/qauth-labs/qauth/blob/main/libs/sdk/resource-guard/README.md). 3. Obtain a valid, **audience-bound** access token and call the protected resource. By the end you will have reproduced the flow from [ADR-007](/reference/records/adr/007-mcp-first-positioning/): an MCP client, given only a server URL, discovers the authorization server, registers, runs `authorization_code` + PKCE, and calls the resource with a token QAuth minted and `resource-guard` validated. ``` ┌──────────────┐ ┌───────────────────────┐ ┌─────────────────────┐ │ MCP client │ │ MCP resource server │ │ QAuth (AS) │ │ (Claude Code,│ │ memory-mcp + │ │ :3000 │ │ curl, …) │ │ resource-guard :8088 │ │ │ └──────┬───────┘ └───────────┬───────────┘ └──────────┬──────────┘ │ GET /mcp/memory (no token)│ │ │ ──────────────────────────▶│ │ │ 401 WWW-Authenticate: │ │ │ Bearer resource_metadata= │ │ │ ◀──────────────────────────│ │ │ GET /.well-known/oauth-protected-resource │ │ ──────────────────────────▶│ │ │ { authorization_servers: ["http://localhost:3000"], … } │ │ ◀──────────────────────────│ │ │ discover AS, register, authorization_code + PKCE │ │ ───────────────────────────────────────────────────────-─▶│ │ access_token (aud = http://localhost:8088, scope mcp:read)│ │ ◀───────────────────────────────────────────────────────-─│ │ GET /mcp/memory Authorization: Bearer │ │ ──────────────────────────▶│ (resource-guard checks sig, │ │ 200 ✅ │ iss, aud, scope) │ │ ◀──────────────────────────│ │ ``` > **Scope of the bundled example.** `memory-mcp` is a minimal Fastify server that > demonstrates the **authorization** half — the 401 challenge, Protected Resource > Metadata, and token validation. It exposes plain HTTP routes (`/mcp/memory`) > rather than the full MCP JSON-RPC transport, so you verify it with `curl`. To > drive it from a real MCP client, wrap your own MCP server with `resource-guard` the > same way (see [Step 3](#step-3--connect-an-mcp-client)); the OAuth handshake is > identical. --- ## Prerequisites - **Docker** 23.0+ and **Docker Compose** 2.0+ (BuildKit on by default) - **OpenSSL** (to generate the JWT signing key) - **Node.js 20+** with `pnpm` and `tsx` (to run the example resource server) - `curl` and `jq` for the walkthrough --- ## Step 1 — Run QAuth (the authorization server) QAuth signs tokens with **EdDSA (Ed25519)**. Generate a key pair and point the stack at it. ```bash git clone https://github.com/qauth-labs/qauth.git cd qauth # 1. Generate the EdDSA signing key pair openssl genpkey -algorithm Ed25519 -out private.pem openssl pkey -in private.pem -pubout -out public.pem # 2. Create your .env from the example cp .env.docker.example .env ``` Edit `.env` and set the keys (include the `BEGIN/END` lines, wrapped in double quotes) and the issuer: ```dotenv DB_PASSWORD=change_me JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- ...contents of private.pem... -----END PRIVATE KEY-----" JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- ...contents of public.pem... -----END PUBLIC KEY-----" JWT_ISSUER=http://localhost:3000 # Let dynamically-registered MCP clients request MCP scopes. # The default allows only OIDC-core scopes (openid profile email offline_access), # so an MCP client's `mcp:read`/`mcp:write` request would otherwise be denied. DEFAULT_DYNAMIC_REGISTRATION_SCOPES=openid profile email offline_access mcp:read mcp:write ``` Start the stack (auth-server + PostgreSQL 18 + Redis 7 + migrations) and verify: ```bash docker compose up -d curl -s http://localhost:3000/ready | jq ``` ```json { "status": "ok", "roles": ["web", "api", "admin", "signer", "delivery"], "checks": { "database": { "status": "ok" }, "redis": { "status": "ok" }, "kek": { "status": "ok" }, "outbox": { "status": "ok" } } } ``` Confirm the discovery document advertises the endpoints an MCP client needs: ```bash curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq ``` ```jsonc { "issuer": "http://localhost:3000", "authorization_endpoint": "http://localhost:3000/oauth/authorize", "token_endpoint": "http://localhost:3000/oauth/token", "registration_endpoint": "http://localhost:3000/oauth/register", "jwks_uri": "http://localhost:3000/.well-known/jwks.json", "code_challenge_methods_supported": ["S256"], "resource_indicators_supported": true, "authorization_response_iss_parameter_supported": true, // RFC 9207 `iss` always emitted "client_id_metadata_document_supported": true, // CIMD on by default } ``` > Interactive API docs (Swagger UI) are served at `http://localhost:3000/docs`. > See the [Docker guide](/operate/docker/) for development mode, CIMD settings, and > production considerations. --- ## Step 2 — Run a guarded MCP resource server The repo ships a runnable example — a tiny in-memory store protected by `resource-guard` in local-JWT validation mode (it verifies tokens against QAuth's JWKS, no per-request call to the AS). ```bash cd libs/sdk/resource-guard QAUTH_ISSUER=http://localhost:3000 \ MCP_RESOURCE=http://localhost:8088 \ npx tsx examples/memory-mcp/server.ts ``` `MCP_RESOURCE` is this server's canonical URL — the `aud` value every accepted token must carry (RFC 8707). In a separate shell, confirm the two unauthenticated behaviours an MCP client relies on: ```bash # Protected Resource Metadata (RFC 9728) — points clients at the AS: curl -s http://localhost:8088/.well-known/oauth-protected-resource | jq # → { "resource": "http://localhost:8088", # "authorization_servers": ["http://localhost:3000"], … } # A protected call with no token → 401 + the Bearer challenge: curl -i http://localhost:8088/mcp/memory # → HTTP/1.1 401 Unauthorized # WWW-Authenticate: Bearer scope="mcp:read", resource_metadata="http://localhost:8088/.well-known/oauth-protected-resource" ``` This is the trigger that starts the whole handshake: a 401 with a pointer to the metadata document and the scopes the operation needs, so a client can authorize in one round instead of guessing. --- ## Step 3 — Connect an MCP client ### Option A — A real MCP client (e.g. Claude Code) Wrap your **actual** MCP server with `resource-guard` exactly as the example does (register the plugin, guard routes with `app.requireBearer` / `app.requireScopes(...)`), then add it to your MCP client as an HTTP server: ```bash # Exact syntax varies by client/version — check `claude mcp add --help`. claude mcp add --transport http memory http://localhost:8088/mcp ``` On first use the client will: 1. call the server, get the **401** + `WWW-Authenticate` challenge, 2. fetch **Protected Resource Metadata** and discover QAuth, 3. **obtain a `client_id`** — via Client ID Metadata Documents (CIMD), or via Dynamic Client Registration if it does not support CIMD (see [below](#client-registration-cimd-vs-dcr)), 4. run **`authorization_code` + PKCE**, opening a browser for **login + consent** at QAuth (you'll register/sign in and approve the `mcp:read` / `mcp:write` scopes), 5. retry the call with the issued token — `resource-guard` validates it and returns `200`. Because the client passes the resource URL as the RFC 8707 `resource` parameter, the token's `aud` is bound to `http://localhost:8088` and is useless at any other resource server. A native client whose callback scheme has no period, such as the Cursor desktop app (`cursor://anysphere.cursor-mcp/oauth/callback`), needs that scheme listed: set `REDIRECT_URI_CUSTOM_SCHEMES=cursor` in `.env`. It is empty by default, and PKCE is what protects the code at any custom scheme; see [Redirect URI schemes for native apps](/operate/docker/#redirect-uri-schemes-for-native-apps). ### Option B — Verify the handshake with curl (no browser) To prove the token half end-to-end without an interactive client, mint a **`client_credentials`** (machine) token. This needs a client whose `scopes` and `audience` are set explicitly, which the seed script provisions. ```bash # The seed script targets an existing realm. The auth-server creates the # default realm ("master", from DEFAULT_REALM_NAME) when it boots, so the stack # from Step 1 already has it. # Provision a machine client bound to the example's resource URL: cat > /tmp/mcp-demo-clients.json <<'JSON' { "realm": "master", "clients": [ { "client_id": "memory-mcp-demo", "name": "memory-mcp demo (client_credentials)", "grant_types": ["client_credentials"], "scopes": ["mcp:read", "mcp:write"], "audience": ["http://localhost:8088"], "token_endpoint_auth_method": "client_secret_basic" } ] } JSON # Seed it (prints the generated client_secret once — capture it): DATABASE_URL="postgresql://qauth:${DB_PASSWORD}@localhost:5432/qauth" \ pnpm nx run infra-db:db:seed-oauth-clients -- --manifest=/tmp/mcp-demo-clients.json ``` Exchange the credentials for an audience-bound token, then call the resource: ```bash CLIENT_ID=memory-mcp-demo CLIENT_SECRET= ACCESS_TOKEN=$(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 -r .access_token) # Call the protected resource — resource-guard verifies signature, issuer, aud, scope: curl -s http://localhost:8088/mcp/memory \ -H "Authorization: Bearer $ACCESS_TOKEN" | jq # → { "subject": "memory-mcp-demo", "client": "memory-mcp-demo", "items": {} } ✅ ``` A token carrying only `mcp:read` against the write route returns a **step-up** challenge — exactly what drives incremental consent in a real client: ```bash curl -i -X PUT http://localhost:8088/mcp/memory/greeting \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' -d '{"value":"hi"}' # → HTTP/1.1 403 Forbidden # WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:read mcp:write", # resource_metadata="http://localhost:8088/.well-known/oauth-protected-resource" ``` (Request `scope=mcp:read mcp:write` at the token endpoint to get a token that satisfies both routes.) --- ## Client registration: CIMD vs DCR QAuth supports two ways for an MCP client to obtain a `client_id`. Both are advertised in discovery, but they are not co-equal: MCP Authorization 2026-07-28 says authorization servers and clients **SHOULD** support CIMD, and **deprecates** RFC 7591 Dynamic Client Registration — retained only for backwards compatibility with parties that do not support CIMD. QAuth keeps DCR working (it is what makes the local walkthrough above practical), but prefer CIMD for anything you deploy. | Mechanism | When QAuth uses it | Best for | | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **CIMD** — Client ID Metadata Documents (MCP 2026-07-28, **preferred**) | `client_id` is an **HTTPS URL** resolving to a metadata document | Production clients with a stable, public metadata URL. A row IS persisted, but idempotently — keyed by the (validated) URL, so re-resolving updates one row instead of creating new ones; there is no record to spam. It is not manageable from the developer portal, by design ([ADR-012](/reference/records/adr/012-dynamic-client-ownership/)): edit the metadata document instead. | | **DCR** — Dynamic Client Registration (RFC 7591, **deprecated** by MCP 2026-07-28) | `POST /oauth/register`, **open mode** (no `initial_access_token`) | Local development and clients without a public URL. Scopes are **capped to the realm allowlist** (`DEFAULT_DYNAMIC_REGISTRATION_SCOPES`) — this is why Step 1 adds `mcp:read mcp:write`. | - **CIMD** is on by default: `CIMD_ENABLED=true` seeds it on for each new realm, and from then on the realm's own `registration.cimd` policy decides ([Changing a realm's CIMD policy](/operate/docker/#changing-a-realms-cimd-policy)). It requires the client's `client_id` URL to be fetchable over HTTPS; the AS validates it (URL == `client_id`, redirect-URI checks, SSRF guards, size/TTL limits). A document may declare portless loopback redirects such as `http://127.0.0.1/callback` (Claude Code does); the client's ephemeral port is accepted at authorization time ([Redirect URI matching](/integrate/oauth-flow/#redirect-uri-matching)). Its `redirect_uris` get the check DCR applies, and like DCR QAuth refuses the whole document if one of them fails it (for example an `http://` URI on a host that is not loopback, or one with a fragment): the client does not resolve, so every request for it gets `invalid_client`. A CIMD client starts with no scopes; the operator grants them in the seed manifest's `cimd_clients` section ([Granting scopes to a CIMD client](/operate/docker/#granting-scopes-to-a-cimd-client)). A document may declare `client_credentials` too, but that grant issues the client no token until its entry lists, in `client_credentials_scopes`, the scopes it may get without a user. For purely local testing over `http://localhost`, CIMD is impractical — use DCR. MCP 2026-07-28 still cites `draft-ietf-oauth-client-id-metadata-document-00`; the IETF draft has since reached **-02** (6 July 2026), and QAuth's implementation already satisfies its added hardening (HTTPS-only fetches with no redirect following, HTTP 200 required, and simple string comparison of the document's `client_id` against the requested one). - **DCR** open mode is rate-limited per IP (30 requests per 60-second window by default — `REGISTER_CLIENT_RATE_LIMIT` / `REGISTER_CLIENT_RATE_WINDOW`). Tighten or gate it for any internet-facing deployment. See [ADR-007 §1](/reference/records/adr/007-mcp-first-positioning/) and the [Docker guide CIMD section](/operate/docker/#client-id-metadata-documents-cimd). --- ## How tokens stay scoped to one resource The no-passthrough guarantee rests on **audience binding** (RFC 8707): - The client sends `resource=` on authorize/token requests. - QAuth mints the access token with `aud` set to that resource and refuses to widen it on refresh. - `resource-guard` rejects any token whose `aud` does not include its own `resource`, and never forwards the inbound token upstream. A token minted for resource A therefore cannot be replayed against resource B, even if both trust the same QAuth instance. `resource-guard` also refuses a sender-constrained token, one bound to a key by `cnf`, such as the DPoP-bound tokens QAuth issues with `DPOP_ENABLED=true`. It answers `401 invalid_token`, because this version verifies no DPoP proof, and it does not accept the `DPoP` authorization scheme. A client that calls it uses an unbound token: it sends no `DPoP` proof to the token endpoint. The tokens of a client marked `dpop_bound_access_tokens`, a token exchanged from a DPoP-bound subject token, and an access token issued for a `cnf`-bound ID-JAG are always bound, so they cannot be used with `resource-guard` until it verifies DPoP proofs. --- ## Troubleshooting | Symptom | Likely cause / fix | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` even with a token | Token `aud` ≠ the server's `MCP_RESOURCE`. Ensure the `resource` you requested matches `MCP_RESOURCE` exactly. | | `invalid_scope` at `/oauth/token` | The client's `scopes` (machine client) or the realm allowlist (DCR client) doesn't include the requested scope. Re-check Step 1's `DEFAULT_DYNAMIC_REGISTRATION_SCOPES` or the seeded client's `scopes`. | | `unsupported scheme: cursor:` | The client calls back on a scheme without a period. List it in `REDIRECT_URI_CUSTOM_SCHEMES` (here `cursor`); see [Redirect URI schemes for native apps](/operate/docker/#redirect-uri-schemes-for-native-apps). | | Seed script: realm not found | The auth-server has not booted against this database yet, or the manifest's `realm` is not `DEFAULT_REALM_NAME`. Start the stack (Step 1) and check its log for the `default realm ready` line. | | `resource-guard` can't fetch JWKS | `QAUTH_ISSUER` must be reachable from the resource server and serve `/.well-known/jwks.json`. | | Port already in use | Override `PORT` for the example, or remap `3000`/`5432`/`6379` in `docker-compose.yml`. | --- ## Next steps - [OAuth 2.1 Flow](/integrate/oauth-flow/) — the `authorization_code` + PKCE flow in detail, with copy-paste `curl` for every step (build your own client). - [Hosted UI](/integrate/hosted-ui/) — the login, consent, and resume screens an end user actually sees during that flow. - [Agent Authorization](/integrate/agent-authorization/) — when an **AI agent** acts on behalf of a user: the `is_agent` client type, RFC 8693 token-exchange delegation, agent scope modes, and step-up before dangerous operations. - [`@qauth-labs/resource-guard` README](https://github.com/qauth-labs/qauth/blob/main/libs/sdk/resource-guard/README.md) — full configuration, `introspection` mode, and the framework-agnostic core. - [ADR-007: MCP-First Positioning](/reference/records/adr/007-mcp-first-positioning/) — why this is QAuth's near-term product identity. - [Docker guide](/operate/docker/) — development mode, CIMD configuration, production. --- # OAuth 2.1 Flow URL: https://docs.qauth.dev/integrate/oauth-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](/integrate/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). --- ## 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) | | `/oauth/end-session` | GET | RP-initiated logout (browser) | Discover these programmatically instead of hard-coding paths: ```bash curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq ``` --- ## Grant types | Grant | Subject (`sub`) | Refresh token? | Use case | | ------------------------------------------------- | --------------- | ---------------------------------------- | ------------------------------------------------------- | | `authorization_code` (+ PKCE) | the end user | `refresh_token` grant + stored consent ¹ | 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) | ¹ Only to a client whose `grant_types` list `refresh_token`, and only under a stored consent that covers the grant (see the [token response](#3-exchange-the-code-for-tokens)). `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](#id-jag--enterprise-managed-authorization-adr-011). --- ## Authorization Code + PKCE (user context) ### 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](#dynamic-client-registration-rfc-7591): ```bash 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, which `DEFAULT_DYNAMIC_REGISTRATION_SCOPES` seeds when the realm is > created; set that env var before the first boot to include any non-OIDC scopes > (e.g. `mcp:read`) you intend to request. An existing realm keeps its own list. ### 1. Generate a PKCE verifier and challenge (RFC 7636) ```bash # 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` 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. | | `login_hint` | no | At most 254 bytes in UTF-8; pre-fills an interaction's sign-in. | | `prompt` | no | `none`, `login`, `consent`, or `create` to register. See below. | | `qauth_upstream` | no | An upstream provider's type; starts the sign-in there. See below. | | `qauth_link` | no | An upstream provider's type; links an account of it. See below. | `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`. #### Routing to an upstream provider `qauth_upstream=` names one of the realm's upstream identity providers, such as `oidc_corp` for a generic OpenID Connect provider, a hidden one included. When the request needs a sign-in, the browser goes straight to that provider instead of the first sign-in step; after the provider's callback the request goes on as any sign-in does, to the consent or the code. A value that names no provider the realm has on is ignored, as the parameter is when no sign-in is needed (a live session that satisfies the request, or `prompt=none`). At most 64 characters; QAuth-defined, so other authorization servers ignore it. #### Linking an upstream account `qauth_link=` links an account of one of the realm's upstream identity providers to the person's QAuth account (#670), as the account console's "Link an account" does. The browser goes to that provider first, then the person signs in to their QAuth account again, under its MFA policy, whatever session the browser already has: a session never links an account on its own (NIST SP 800-63C-4 §5.4). That sign-in takes the account's own factors, or another of the realm's providers whose identity the account already holds, never the provider being linked (#1080). The identity joins the account that sign-in proved, and the request goes on to the consent or the code as after any sign-in. An identity another account already holds is refused on a QAuth page, as is a second identity of the same provider; nothing reaches your `redirect_uri` then. The parameter is honoured only for a client the operator flagged first-party (ADR-017 Decision 2), while the Interaction API is on (`INTERACTION_UI_ENABLED`), and only for a provider the realm has on, hidden or not; otherwise it is ignored. It takes precedence over `qauth_upstream`. At most 64 characters; QAuth-defined. The account API lists the linked accounts and unlinks one ([`/account/v1-rc.0/linked-accounts`](/integrate/api-reference/#get-accountv1-rc0linked-accounts)). #### Registration: `prompt=create` `prompt=create` ([OpenID Connect Prompt Create 1.0](https://openid.net/specs/openid-connect-prompt-create-1_0.html)) sends the person to register a new password account instead of signing in. Discovery lists `create` in `prompt_values_supported` only while the realm allows sign-up. The request is refused, by a redirect to your `redirect_uri` with `state` and `iss`, when: | Cause | `error` | | --------------------------------------------------------------- | --------------------- | | `scope` does not hold `openid` | `invalid_request` | | the realm does not allow sign-up, or the Interaction API is off | `invalid_request` | | your client's `hosted_flows` do not name `register` | `unauthorized_client` | | the request carries `max_age`: a registration signs nobody in | `invalid_request` | A client that registered itself gets these errors on a QAuth page instead of a redirect, as it gets every error before a sign-in. Which clients may register, and how the realm turns sign-up on, is the operator's choice: [Clients that may start a registration](/operate/docker/#clients-that-may-start-a-registration-hosted_flows). The registration asks for the address first, then for an 8-digit code mailed to it, then for a new password that meets the realm's password policy. The screens are the same whether or not an account already holds the address: only the mail differs, and an address that has an account gets help to sign in to it instead. The account is created once the code and the password pass. The consent screen follows as for a sign-in, and the client gets its code. A registration is not a sign-in: QAuth starts no browser session, and the ID token carries no `auth_time` and no `amr`. A request for a dangerous scope asks the person to sign in first. A browser that is already signed in can still register a new account, and its session stays as it was. `qauth_upstream` and `qauth_link` are ignored with `prompt=create`. #### Password reset With the Interaction API on (`INTERACTION_UI_ENABLED`), the password step of a sign-in offers a reset beside the password, to every client the realm serves. It asks for an 8-digit code mailed to the address, then for a new password that meets the realm's password policy. The screens are the same for every address: only an account with a password and a verified address gets the code. A disabled account gets no mail and no change, and neither does an account whose address was never verified, until such an account is replaced at the first proof of its mailbox ([#641](https://github.com/qauth-labs/qauth/issues/641)). An account that also holds a passkey or another authenticator must prove it before the new password; until those steps are served on these pages, such a reset ends as an expired interaction and changes nothing. A reset ends everything the old password opened: every refresh token of the user is revoked, every authorization code not yet exchanged is invalidated, every browser session and every sign-in in progress that began before it is ended, and the owner is mailed that the password changed. A session that began within a second after the reset is ended too: that margin covers the clocks of two server nodes. Like a registration, a reset is not a sign-in: QAuth starts no browser session, and the ID token carries no `auth_time` and no `amr`. A request that asks for a fresh sign-in, with `prompt=login` or `max_age`, offers no reset. #### Redirect URI matching `redirect_uri` is compared with the client's registered set (or, for a [CIMD](/integrate/mcp-quickstart/) 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 custom-scheme redirects, reverse-domain or listed in `REDIRECT_URI_CUSTOM_SCHEMES`, 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](/integrate/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. ### 3. Exchange the code for tokens ```bash 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 ``` ```json { "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. A missing `redirect_uri` is `invalid_grant`, like a different one, with the `error_description` `redirect_uri is required for this code`: QAuth keeps RFC 6749's rule for codes from a browser redirect, although OAuth 2.1 drops the parameter. The code is not spent, so send the request again with it. - **Redeem a code once.** A second redemption is refused with `invalid_grant` and, when it is otherwise a valid request (your client, the same `redirect_uri`, the right `code_verifier`, DPoP key and `resource`), revokes every token the first one led to (OAuth 2.1 §4.1.3): the refresh tokens of its family, and every access token issued from the code or in that family, which userinfo, introspection and token exchange then refuse. QAuth cannot tell a retry from an attacker who copied the code. So do not retry a token request that may have reached the server; send the user through `/oauth/authorize` again instead. A code is recognised as spent until the hourly purge deletes it, some time after its five-minute lifetime. - `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 your client's `grant_types` list `refresh_token` and the user has a stored consent** for your client that covers the granted scopes at every `resource` the token is bound to — the row they can review and revoke under `/consents`. A client registered for `authorization_code` alone gets no refresh token, however its consent was given: the `refresh_token` grant would refuse it with `unauthorized_client`. A consent remembered for one resource server does not cover another. 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: the grant and the consent decide. - **`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](#id-token-claims-oidc) below. `client_credentials` has no end user and never carries one; `refresh_token` does **not** reissue one either (see [Refresh Token](#refresh-token-rotation)). ### 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:176-226`): | Claim | Present when | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `nonce` | The authorize request sent one (OIDC Core §3.1.3.6) — echoed back unmodified. | | `auth_time` | A sign-in backs the code: the session's real authentication time, epoch seconds. Not after a registration or a reset. | | `amr` | A sign-in backs the code and stored its methods: RFC 8176 values, `["pwd"]` for a password. Not after a registration or a reset, nor for a session from before QAuth stored them. | | `sid` | A browser session backs the code: its session id, the value the session cookie names. | | `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. | `sid` names the browser session the user signed in with on the auth server, and is what back-channel logout will name when that session ends (ADR-019 Decisions 3 and 4). A code from the headless Bearer hand-off of `/oauth/authorize` has no browser session, so its ID token has no `sid`. A sign-out spends the codes minted under the session that are not yet redeemed. The access token, the introspection response and the refresh token never carry `sid`, and discovery does not advertise it yet. `email`/`email_verified` share the same trust-ordered resolution the access token and [UserInfo](#userinfo-oidc) 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. The access token of the code flow also carries the ID token's `auth_time` and `amr`, and every refresh of its family keeps them as they were (RFC 9068 §2.2.1): they say when and how the user signed in, not when the token was issued, so a resource server can require a recent sign-in or a given method. Discovery lists both in `claims_supported`. The tokens of the other grants carry neither. ### 4. Call a protected resource ```bash curl -s http://localhost:8088/mcp/memory \ -H "Authorization: Bearer ACCESS_TOKEN" | jq ``` A resource server (e.g. one using [`resource-guard`](https://github.com/qauth-labs/qauth/blob/main/libs/sdk/resource-guard/README.md)) verifies the signature against the JWKS and checks `iss`, `exp`, `aud`, and scope. `resource-guard` refuses a DPoP-bound token (one carrying `cnf`), because it verifies no DPoP proof yet. ### Caching the JWKS Every token names the key that signed it in `kid` (a hybrid token names its ML-DSA-65 key in `pqc_kid` too). A realm's JWKS lists every key that signs or may still verify: its active keys, a next key before it starts signing, and a retired key until its retention ends. A relying party or resource server that verifies tokens locally follows three rules: - **Cache the JWKS for at most 5 minutes.** `/.well-known/jwks.json` answers `Cache-Control: public, max-age=300`. - **Fetch it again when a token names a `kid` the cached set lacks, at most once a minute.** A token whose `kid` is still unknown after that fetch is refused, not retried. - **Drop every key the JWKS no longer lists.** A key leaves the JWKS when its retention ends, or when it is retired as compromised; a token it signed must stop verifying then. A compromised key is not gone everywhere at once: QAuth's own servers stop using it within their 60-second key refresh, and your JWKS cache adds its own lifetime before your verifier stops. Pick the key by `kid` and `alg` together, never by `kid` alone: the JWKS of a realm with hybrid signing holds an `OKP` and an `AKP` key, and retired keys sit beside active ones. `resource-guard` follows these rules with its default `jwksCacheTtlMs` of 5 minutes, which is also the least time between two fetches. A rotation through QAuth's key store then changes nothing for users or relying parties: the new key is in the JWKS before it signs, and the old one stays in it, and keeps verifying at QAuth's own endpoints, until its retention ends ([Keys](/operate/keys/)). That holds only once rotation through the key store ships (#542 and #544). Until then a signing key is replaced through its variable, which is not a rotation: the new key is published only as it starts signing, and the old key leaves the JWKS at once, so the tokens it signed stop verifying ([Replacing a signing key](/operate/keys/#replacing-a-signing-key)). --- ## 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, and the access tokens issued in it. 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. ```bash 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 ``` - Only a client whose `grant_types` list `refresh_token` may refresh; any other gets `unauthorized_client`, and the code exchange gives it no refresh token in the first place. - The response contains a **new** `refresh_token` — store it and discard the old. - Each new refresh token is good for the realm's refresh token lifetime (`tokens.refreshTokenLifespanSeconds`, a week by default) from the refresh. When the realm sets `sso_max_lifespan`, the family also ends that long after it starts. A family starts at the code exchange or at `POST /auth/login`. A family started by a code exchange also ends no later than the user's browser session that the authorization ran in. No token of the family expires later, and a refresh at or after that point fails with `invalid_grant` and revokes the whole family. Send the user through `/oauth/authorize` again. A silent `prompt=none` authorization just before the session ends gives a family that ends with the session. - A **browser-based client** gets no more time on a refresh, whatever the realm sets (RFC 10017 §6.3.2.3, OAuth 2.1 §9.3.2): for a public client with an `https` redirect URI, each new refresh token expires when the one it replaces does, so the whole family ends when its first token expires, a week after the code exchange by default. A refresh then fails with `invalid_grant`; send the user through `/oauth/authorize` again, silently with `prompt=none` while the browser session lasts. Confidential clients and native apps, whose every redirect URI is a loopback (`http://127.0.0.1/…`) or private-use scheme (`com.example.app:/…`) one, keep the expiry that slides with each refresh. A native app that calls back on a claimed `https` URI is treated as browser-based, since the server cannot tell that URI from a web page's. - `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. --- ## Client Credentials (machine-to-machine) No user, no browser. The token's `sub` is the `client_id` and no refresh token is issued. ```bash 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). - A client that also signs users in can have its `client_credentials` grant narrowed by the operator to the scopes it needs as itself (`client_credentials_scopes` in the seed manifest): a scope outside that list is refused with `invalid_scope` even though the client holds it, so the scopes its users grant it never reach a token that names no user. A client without the list may request any scope of `scopes`, except a CIMD client, whose document chose the grant: it gets no `client_credentials` token until the operator lists the scopes in its `cimd_clients` entry ([Tokens a CIMD client gets as itself](/operate/docker/#tokens-a-cimd-client-gets-as-itself)). See [SSF transmitter](/operate/ssf-transmitter/#register-a-receiver). - `resource` must fall within the client's configured `audience` (or defaults to the `client_id`); it sets the token `aud`. - An audience in the reserved `urn:qauth:aud:` namespace, such as the developer API's `urn:qauth:aud:developer:v1-rc.0`, is issued only to a client whose `audience` lists it, on every grant; any other client gets `invalid_target`. See [API conventions](/reference/api-conventions/#issuing-a-family-audience). - Provision such clients with the seed script (it lets you set `scopes` and `audience` explicitly) — see the [MCP Quickstart, Option B](/integrate/mcp-quickstart/#option-b--verify-the-handshake-with-curl-no-browser). --- ## 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](https://github.com/modelcontextprotocol/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](/integrate/agent-authorization/). 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). ```bash 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): ```jsonc { "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 recorded as an event (`token.issued`, `token.request_failed`), including the acting agent, its delegation chain and the delegation depth. --- ## 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. ```bash 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) 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. - **Public keys only.** A key set holding any private member (`d`, the RSA primes and CRT values `p`, `q`, `dp`, `dq`, `qi` and `oth`, an ML-DSA `priv` or a symmetric `k`) is refused whole, and so is a key without the public members its type needs (EC `crv`, `x` and `y`; OKP `crv` and `x`; RSA `n` and `e`). The assertion is refused with `invalid_client`. A key whose private members were published is exposed: replace it with a new key, not a trimmed copy of the old one. - **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) 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) 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. That `resource` must be among the client's ID-JAG targets, which an operator sets with `id_jag_targets` in the seed manifest; an empty list denies, and the client's `audience` does not count. See [Provisioning a client for ID-JAG](/operate/docker/#provisioning-a-client-for-id-jag). ```bash 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 ``` **An assertion bound to a DPoP key.** The IdP may bind the ID-JAG to the client's DPoP key with a `cnf` claim, `{"cnf": {"jkt": ""}}` (`-04` §9.8.1). QAuth then redeems it only with a `DPoP` proof under that key, which needs `DPOP_ENABLED=true`, and the access token it issues is bound to the same key (`cnf.jkt` in the token, `token_type: DPoP`). Without a proof the answer is `invalid_grant` with the description "proof of possession is required for this assertion"; the assertion is not spent, so send it again with a proof. A proof under another key, a server with DPoP off, and a `cnf` that names any confirmation method but `jkt` are refused with the bare `invalid_grant`. The grant type stays `jwt-bearer`: `-06` names `urn:ietf:params:oauth:grant-type:jwt-dpop` for a bound ID-JAG, and QAuth follows `-04`, the revision the EMA extension names. An assertion without `cnf` works as before: a proof binds the token to its key, and without one the token is a Bearer token. **Rotating the IdP's signing key.** QAuth caches the issuer's discovery document and JWKS for `ID_JAG_JWKS_CACHE_TTL`. An assertion whose `kid` is not in the cached set makes QAuth fetch both again, but at most once per issuer per `ID_JAG_JWKS_REFRESH_COOLDOWN` (30 seconds by default); inside that window the assertion is refused. Publish a new key in the JWKS at least one cooldown before the IdP signs with it, and no assertion under it is refused while the IdP stays reachable. This needs a `kid` in every assertion: without one, the JWKS must hold exactly one key, so an IdP that signs without `kid` cannot publish two at once. While the IdP cannot be reached, QAuth tries it again only every `ID_JAG_JWKS_NEGATIVE_CACHE_TTL` (30 seconds by default); keys it has already cached keep working, and a key it has not seen waits for that window too. ### 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 `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`. **No re-submission.** The draft (`-04` §4.4.3) lets a client re-submit the same ID-JAG for a new access token while the assertion is unexpired. QAuth refuses the second presentation with `invalid_grant`. When the access token expires, ask the IdP for a fresh ID-JAG. With the default lifetimes nothing changes in practice: an assertion lives at most 5 minutes and the access token it buys at least 15, so the assertion expires first. 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 recorded as the event's `reason` code and in the server log, and never sent on 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. The one exception is a DPoP-bound assertion sent without a proof, which says a proof is required: only the authenticated client the assertion names gets that far, after every other check passed. ### 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 ID-JAG targets (`id_jag_targets` in the seed manifest). An empty list denies. The client's `audience`, which bounds the tokens QAuth issues, does not count. - 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](/reference/records/adr/011-enterprise-managed-authorization/) §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. --- ## RP-Initiated Logout `GET` or `POST /oauth/end-session` signs the browser out of QAuth at an app's request, then sends it back to the app (OpenID Connect RP-Initiated Logout 1.0). Discovery lists it as `end_session_endpoint`. The app sends the browser there with a link, a redirect or a form post. It is not an API call. The session cookie is `SameSite=Lax`, so a browser leaves it out of a form post from another site; such a POST is answered `303` to the same endpoint as a `GET` with the same parameters, which carries the cookie. A large `id_token_hint` then travels in the URL, so prefer `GET`. ```text https://auth.example.com/oauth/end-session ?id_token_hint=ID_TOKEN &client_id=CLIENT_ID &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out &state=af0ifjsldkj ``` | Parameter | Notes | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id_token_hint` | An ID token QAuth issued to the app. Its signature, issuer and `typ` must verify; an expired one is accepted (§2). One that does not verify counts as absent. | | `client_id` | The app. With `id_token_hint`, it must be the hint's audience, or the request is refused. | | `post_logout_redirect_uri` | Where the browser goes afterwards. It must be one of the app's registered `post_logout_redirect_uris`, matched exactly. Any other value gets an information page and never a redirect. | | `state` | Added to `post_logout_redirect_uri` when the browser goes back. | | `ui_locales`, `logout_hint` | Accepted and not read. | What happens: - **The hint names the signed-in account.** The browser's session ends at once: QAuth revokes it, clears the session cookie and goes back to the registered URI with `state`, or shows "You are signed out". The hint's `sid` must be this session's: a hint from another session of the account, or one without `sid` (an ID token minted before ID tokens carried it), is suspect (§2) and gets the confirmation step. So does a hint of an app that was deleted, disabled or suspended since. - **Any other signed-in browser** gets a confirmation step: sign out, sign out of all accounts, or stay signed in. Nothing ends until the person answers. So a request without a valid hint, such as a cross-site image tag, ends nothing. The browser then goes back to the registered URI with `state`, whatever the person chose, or to the sign-out status page (`/oauth/end-session/status`), which says whether the browser is still signed in. - **A browser that is not signed in** goes back to the registered URI, or sees "You are signed out". - **An app the operator did not set up** (open dynamic registration, a CIMD document, the developer API) chose its own return URIs, so QAuth does not send the browser there before it has shown a page of its own (RFC 9700 §4.11.2, as for the authorization endpoint's errors). When the session ends at once, or there is none, "You are signed out" names the host of the return URI and links to it, with `state`. After the confirmation step the browser goes back as for any app. - **A refused request** (a `client_id` that is not the hint's audience, a return URI with no client named, a URI the client did not register, or a disabled client) gets a `400` information page. Nothing ends and the browser is not redirected. The route ends the browser session only. It revokes no refresh token: an app that signs out revokes its own refresh token at [`/oauth/revoke`](#token-revocation-rfc-7009) first, then sends the browser here. Each outcome is recorded as `user.signed_out`, which names the ended session (`sid`), or as `user.sign_out_failed` with the reason. QAuth sends no front-channel logout; it tells apps by [back-channel logout](#back-channel-logout). Register return URIs as `post_logout_redirect_uris`: in the [registration request](#dynamic-client-registration-rfc-7591), in a CIMD document, or in the seed manifest. They follow the same scheme rules as `redirect_uris`. A client that registered none is never redirected. A CIMD document whose list breaks a rule registers none, and its client still resolves. ### Back-channel logout When a browser session ends, QAuth POSTs a logout token to every app the session signed in to that registered a `backchannel_logout_uri` ([OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)). Whatever ended the session sends it: this endpoint, `POST /auth/logout`, a password reset, an admin, a retired key, the browser's session limit, or the session lapsing. Discovery says so with `backchannel_logout_supported` and `backchannel_logout_session_supported`. ```http POST /backchannel HTTP/1.1 Host: app.example Content-Type: application/x-www-form-urlencoded logout_token=eyJhbGciOiJFZERTQSIsInR5cCI6ImxvZ291dCtqd3QiLCJraWQiOiIuLi4ifQ... ``` The token is a JWT with `typ: logout+jwt`, signed with the key and the algorithm of your ID tokens (§2.4), which the realm's `jwks_uri` publishes: verify it as you verify an ID token, and tell the two apart by `typ` and by `events`. Its claims are `iss`, `aud` (your `client_id`), `iat`, `exp` (two minutes later), `jti`, `events` with the member `http://schemas.openid.net/event/backchannel-logout` and an empty object, `sub` (the user, as in your ID tokens) and `sid` (the session, as in your ID tokens). It never carries `nonce`. Validate it as §2.6 says, then end your own session for that `sid`, or every session of that `sub`, and answer `200` or `204`. - **One token per app and session.** Each attempt signs a new token with a fresh `iat` and `exp`; a retry keeps the `jti`, so an app that keeps the `jti` values it has seen can tell a retry it already acted on. - **Retries.** A `5xx`, `408` or `429` answer, a timeout or a network failure is retried on the server's delivery schedule. Any other answer, such as a `400`, is final, as §2.5 asks. - **Sign-out never waits.** The session ends first; the token is queued in the same database transaction and sent afterwards, so an app that is down never holds anyone signed in, and a server that stops loses no token. - **`backchannel_logout_session_required: true`** asks for `sid` in every token. QAuth always sends it. Register the URI as `backchannel_logout_uri` in the [registration request](#dynamic-client-registration-rfc-7591), in a CIMD document, or in the seed manifest. It must be an absolute `https` URL with a host, no user name or password and no fragment. A URI you register yourself must resolve only to public addresses: a private, loopback or link-local address is refused at registration, even for a development client, and checked again before every token. Only the operator, through the seed manifest, may set a URI the deployment's egress allowlist opens. A CIMD document whose URI breaks a rule registers none, and its client still resolves. --- ## 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. ```bash 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 Every way of ending a grant stops new tokens being minted from it: | Revocation | Ends | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `/oauth/revoke` with a refresh token | That token's whole rotation family | | A replayed refresh token, or a replayed authorization code | The whole rotation family, and every access token issued in it or from the code (denylisted by `jti` until it would have expired) | | `/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 | | `/oauth/end-session` ([RP-initiated logout](#rp-initiated-logout)) | One browser session and its unredeemed authorization codes; no refresh token | **Otherwise, access tokens already issued under the grant are not reached.** They are stateless JWTs and stay valid until `exp` 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`, or by a replay of the grant it came from, is refused before then, and only where the denylist is consulted: introspection, userinfo, token exchange and QAuth's own bearer-protected routes, not a resource server that verifies the JWT locally. A replay revokes the access tokens QAuth recorded when it issued them, in Redis; one issued while Redis could not record it, or a replay while Redis is unavailable, leaves the token valid until it expires, and the server logs it. A short access-token lifetime is what bounds this window: the realm's `tokens.accessTokenLifespanSeconds` (900 s by default) for a staging or production client, its `tokens.developmentAccessTokenLifespanSeconds` (8 hours by default) for a development client, each at most a day, and at most its `tokens.adminAccessTokenLifespanSeconds` (600 s by default, at most 900 s) for a token whose audience is the admin API, which is also bound to a DPoP key ([API conventions](/reference/api-conventions/#the-dpop-check-of-the-admin-listener)). 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) Resource servers can validate opaque or near-real-time-revocable tokens by asking the AS. Requires **confidential** client authentication. ```bash 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 ``` ```json { "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](/reference/status/)), 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. --- ## UserInfo (OIDC) Return the authenticated end user's claims for a user-context access token: ```bash 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](#id-token-claims-oidc) 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) `POST /oauth/register` is **open** (no `initial_access_token`) and rate-limited. See the example in [step 0](#0-prerequisites--a-client). Key fields: | Field | Notes | | ---------------------------- | ----------------------------------------------------------------------- | | `redirect_uris` | Required for `authorization_code`. Schemes: see below. | | `post_logout_redirect_uris` | Optional. Where [RP-initiated logout](#rp-initiated-logout) may return. | | `backchannel_logout_uri` | Optional. Where [back-channel logout](#back-channel-logout) POSTs. | | `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**. No `qauth:` scope. | 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`. A scheme without a period (`myapp:`) is accepted only when the server's operator lists it in `REDIRECT_URI_CUSTOM_SCHEMES`, as for the Cursor desktop app's `cursor:`. Any other scheme, `javascript:`, `data:` and `file:` 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. A scope the realm allowlist does not list is dropped, and the client is registered with the rest (RFC 7591 §2 lets the server narrow the request). The `qauth:` scope namespace, in any case, is QAuth's own: the admin API's scopes (`qauth:admin:read`, `qauth:admin:write`, `qauth:admin:security`) live in it, and only an operator provisions their clients. A realm's allowlist cannot hold such a scope, so registration always drops one; a client that echoes a resource's `scopes_supported` still registers. A CIMD document's `scope` is never stored, so one there is ignored. Other servers' scopes that look administrative, such as `admin:org`, are ordinary scopes. The three admin scopes and what they allow are on the [admin permissions](/reference/admin-permissions/) page. QAuth's API audiences, the `urn:qauth:aud:` namespace in any case, follow the same rule: self-registration never obtains one. Registration ignores one in an `audience` or `resource` member and registers the client with the rest, as it ignores every other value there, and a CIMD document that names one resolves with that value ignored. Only a client an operator set up receives such an audience (see [API conventions](/reference/api-conventions/#issuing-a-family-audience)). For MCP clients, **CIMD** (an HTTPS-URL `client_id`) is the recommended alternative to DCR — see the [MCP Quickstart](/integrate/mcp-quickstart/#client-registration-cimd-vs-dcr). --- ## 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. On the OAuth protocol > endpoints the body holds only `error`, the bare registered code, and > `error_description`, the human-readable detail, where there is any to give. > Every other route answers an RFC 9457 problem document instead; see the > [error model](/integrate/api-reference/#error-model). > > `/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 — a `client_id` that names > no client, and a `redirect_uri` that is not registered or not permitted for > the environment — reach the caller in the JSON body instead, as > `invalid_request`. 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. > > **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. | Code | Meaning | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `invalid_request` | Missing/malformed parameter. | | `invalid_client` | Client authentication failed or client unknown. | | `invalid_grant` | Bad, expired or already-redeemed code, wrong or missing `redirect_uri`, 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, or a QAuth API audience (`urn:qauth:aud:`) the client's `audience` does not list. | | `unsupported_grant_type` | A `grant_type` the token endpoint does not serve; a missing one is `invalid_request`. | | `invalid_client_metadata` | Dynamic registration: a metadata value is not valid or not allowed (RFC 7591 §3.2.2). | | `invalid_redirect_uri` | Dynamic registration: a redirect URI is not valid or not allowed (RFC 7591 §3.2.2). | | `access_denied` | Dynamic registration with a developer token: the developer already has as many clients as the realm allows (HTTP `403`). RFC 7591 defines no error for this refusal; QAuth answers RFC 6749's `access_denied`. | | `invalid_token` | Userinfo, and dynamic registration with a developer token that is not valid: the access token is missing, not valid, or names no active user (RFC 6750 §3.1). A request without one gets a challenge with no error code. | | `invalid_dpop_proof` | With DPoP on, the DPoP proof is missing or not valid (RFC 9449). | | `temporarily_unavailable` | HTTP `503` with `Retry-After` when the server is too busy to check the client secret now, or `429` when the rate limit refuses the request; retry after the delay. | | `server_error` | HTTP `5xx`: the server failed. The answer says nothing more; its `X-Request-Id` finds the log line. | --- ## See also - [MCP Quickstart](/integrate/mcp-quickstart/) — end-to-end QAuth → MCP handshake. - [Hosted UI](/integrate/hosted-ui/) — the login, consent, and resume screens. - [Agent Authorization](/integrate/agent-authorization/) — the agent-native layer (`is_agent`, scope modes, step-up, per-agent audit) built on these grants. - [`@qauth-labs/resource-guard`](https://github.com/qauth-labs/qauth/blob/main/libs/sdk/resource-guard/README.md) — resource-server SDK that validates these tokens. - [ADR-006: OAuth grants and audience](/reference/records/adr/006-oauth-grants-and-audience/). --- # Hosted UI URL: https://docs.qauth.dev/integrate/hosted-ui/ `authorization_code` + PKCE ([OAuth 2.1 Flow, step 2](/integrate/oauth-flow/#2-redirect-the-user-to-oauthauthorize)) sends the browser to QAuth itself for a stretch. Three server-rendered pages handle that stretch — `/ui/login`, `/ui/consent`, and `/ui/resume/{handle}` — and every integrator meets all three, even one that never renders its own login UI. ## `/ui/login` `GET /ui/login?return_to=` renders an email/password form. `return_to` must be a relative, same-origin path — `isSafeReturnTo` (`apps/auth-server/src/app/helpers/return-to.ts:50`) rejects absolute URLs, `//host` protocol-relative values, and the `/\host` backslash variant a browser also resolves as protocol-relative, falling back to `/` on anything that fails. The page carries a signed, double-submit login-CSRF token in both a hidden form field and a cookie. `POST /ui/login` verifies the CSRF pair, then the credentials. On success it mints a **fresh** session id (session-fixation defence — a pre-login session id is never reused), sets it as `__Host-qauth_session`, and redirects to `return_to`. On failure it re-renders the form, and the two failure modes handle the CSRF token differently: a `403` (CSRF pair missing or mismatched, `apps/auth-server/src/app/routes/ui/login.ts:312`) mints and sets a **fresh** token so a legitimate retry can succeed; a `401` (bad credentials, `apps/auth-server/src/app/routes/ui/login.ts:347`) **reuses** the existing cookie's token unchanged, since that cookie is still valid — only the credentials were wrong. An account whose address is not verified yet gets that same `401` page while the realm requires verified accounts (`accounts.requireVerifiedAccount`, the default), so the page cannot be used to tell a just-registered free address from a registered one; its message mentions confirming the address for that reason. The session is stored in Postgres, with Redis as a cache in front of it, in the realm of the host the browser signed in on: the cookie names no session at another realm's host. The cookie lasts until the session's absolute end: `SESSION_COOKIE_TTL` from sign-in, or the realm's `sso_max_lifespan` when it sets one, and is not extended by use. The session behind it can end sooner, when the realm sets `sso_idle_timeout`. [`POST /auth/logout`](/integrate/api-reference/#post-authlogout) ends every hosted session of the user, so the next `/oauth/authorize` shows this page again. ## `/ui/consent` `GET /ui/consent` takes the same query parameters as [`/oauth/authorize`](/integrate/api-reference/#oauth-21) and renders the scope consent screen. It requires a valid `__Host-qauth_session` cookie; without one it redirects to `/ui/login` (via the pending-authorization mechanism below, not a raw inline redirect). `POST /ui/consent` submits the decision (`allow` / `deny`, CSRF-protected) and, on `allow`, issues the authorization code and redirects to the client's `redirect_uri` exactly as a first visit would. The **Remember this decision** checkbox (ticked by default) stores the consent, which lets later requests for the same scopes skip the screen and lists the client under `/consents`. Unticked, the approval is one-time: nothing is stored, and `/oauth/token` returns no `refresh_token` for that code. The screen names the client by what the server verified about it, never by the name the client gives itself ([ADR-019](/reference/records/adr/019-deployment-topology-and-trust-boundaries/) Decision 6): - A client whose `client_id` is a Client ID Metadata Document URL is shown by that URL's host, in its ASCII (`xn--`) form, with the registrable domain in bold. A host under a shared-hosting suffix (the private section of the Public Suffix List) is flagged. The host is all the screen shows, and all the server verified: with the default `accept-any-https` trust policy, a document anyone can publish on a host that serves anyone's files appears under that host. A deployment that accepts third-party agents should give each realm the `allowlist` trust policy ([CIMD](/operate/docker/#what-the-consent-screen-shows-for-a-cimd-client)). - A client the operator provisioned is shown by the domain the operator set on it: `verified_domain` in the `db:seed-oauth-clients` manifest, or `db:set-client-verified-domain` for a client that exists already. - Any other client, a dynamically registered or developer-portal one included, is shown as an unverified app, with the host that receives the code. A redirect to a loopback address is shown as "this device", with a warning. The name the client declared comes after all of this, labelled as declared, cut to 64 characters and stripped of control and bidirectional characters. No logo or link the client supplied is shown. Since the screen always says where the code goes, registration refuses a redirect URI it could not show: a host that is not a DNS name of valid labels (an underscore, an empty label), an IPv6 address other than loopback, or a private-use scheme longer than 64 characters. ### An AI agent client For an agent client (one with `is_agent`, see [Agent Authorization](/integrate/agent-authorization/#1-agent-client-type-is_agent)), the screen adds two lines after the "Signed in as" line. They are QAuth's own text, never anything the client declared: - **"This app is an AI agent."** The name the client declared stays in its own line, labelled as declared. - **How long the agent may keep working.** In a realm without `sso_max_lifespan`: "It may keep working until you revoke it." In a realm that sets one: "It may keep working until you revoke it, or until <the limit> after you signed in, whichever comes first." The limit is written as an exact duration that names every unit it holds: `3600` is "1 hour", `5400` "1 hour and 30 minutes". The second line is the end of the agent's refresh-token family. Without a limit each refresh moves the family's end forward, until the user revokes the consent under `/consents`, except for a browser-based agent (a public client with an `https` redirect URI), whose family ends when its first refresh token expires. With one, the family ends no later than its start plus the limit and the end of the browser session behind the code, and that session ends the limit after the sign-in ([Refresh Token](/integrate/oauth-flow/#refresh-token-rotation)). Three things the line leaves to this page: - **The family needs a stored consent.** The code exchange issues a refresh token only under one: the approval with **Remember this decision** ticked, or a consent the user remembered on an earlier visit, which unticking the box later does not remove (revoke it under `/consents`). With neither, no refresh token is issued and the agent stops when its access token expires. - **An access token outlives both ends.** Revoking the consent and the family's end stop refreshes; an access token already issued works until it expires, after the realm's access token lifetime (`tokens.accessTokenLifespanSeconds`, 15 minutes by default) or, for a `development` client, its `tokens.developmentAccessTokenLifespanSeconds`. - **The limit is the one the page read.** The page states the realm's limit as it is when the page renders. A limit the operator changes later applies to the family as the Refresh Token section describes, not as the page said. A browser session keeps the end it was given at sign-in, so after a realm raises `sso_max_lifespan`, a family started from a session signed in before the change ends at the earlier of its start plus the new limit and that session's own end, which the old limit set. A stored consent never skips the screen for an agent client (ADR-015 Decision 11): every authorization request from an agent shows it, and `prompt=none` from an agent answers `consent_required` with no code. The decision is taken for the page the user saw. A client whose `is_agent` changes between the page and the decision (a Client ID Metadata Document can change it) gets nothing from that decision: the browser goes back to `/oauth/authorize`, which shows the page the client needs now. ### A first-party client An operator can mark a client as first party: the seed manifest's `first_party_profile` ([Docker](/operate/docker/)), which no registration path can set ([ADR-017](/reference/records/adr/017-first-party-login/) Decisions 2 and 9). For such a client the operator's decision stands in for the user's consent, an administrative consent: - `/oauth/authorize` issues a code without this screen when none of the requested scopes is dangerous (`write:*`, `agent:admin`, `agent:exec`) and the request does not carry `prompt=consent`. Under `prompt=none` it issues the code too, for a `web_redirect` client. - The waiver stores nothing for the user. Unless the user once remembered a consent for the client on this screen, the client is not listed under `/consents`, and `/oauth/token` returns no `refresh_token` for such a code, as for an approval with **Remember this decision** unticked. A remembered consent that covers the code's scopes, at each resource indicator the code is bound to, still yields one. - Every such code is recorded as a [`consent.administrative`](/reference/events/#consentadministrative-v1-rc0) event: the profile it rests on, the scopes and resource indicators it covered, and the endpoint. - A dangerous scope or `prompt=consent` shows this screen, as for any client, even when a stored consent covers the request. Under `prompt=none` such a request answers `login_required` when a fresh sign-in is due (a dangerous scope on a sign-in more than two minutes old), else `consent_required`. - A client whose profile is `native_backend`, the backend of a native app, has every request treated as `prompt=login`, whatever the request says: a browser that already holds a session meets the login page unless its sign-in is less than two minutes old, and `prompt=none` answers `login_required` however recent the sign-in. A `POST /ui/consent` for such a client asks for the same fresh sign-in. So a native app's browser leg completes only on a sign-in of the last two minutes, the window `prompt=login` gives every client, never on an older session. Its own `prompt=consent` still shows this screen. A client whose profile does not hold, because its row breaks one of the profile's rules or its policy does not parse, is treated as a client without one. ## Upstream providers on the login page When the login page was opened for an authorization request, it lists the realm's upstream identity providers, each as a plain link that works without JavaScript: "Sign in with ", in the realm's order, a hidden provider left out (#639). `GET /ui/upstream/{type}?return_to=` sends the browser back to that authorization request with [`qauth_upstream=`](/integrate/oauth-flow/#routing-to-an-upstream-provider), reading a parked request (`/ui/resume/{handle}`) without spending it. The authorization endpoint starts an interaction for the request and sends the browser to `GET /ui/upstream/{type}/start?interaction=`, which starts the provider and answers a waiting page: it goes on to the provider at once, with a link for a browser that does not follow the refresh and a way back. The provider sends the browser back to `/upstream/{type}/callback` on the realm host. Only the browser that started may complete the sign-in, once; the account is found by the provider's subject under its issuer, never by email, and a new one is created only when the realm allows sign-up (`ALLOW_SIGN_UP`). On success the browser goes back to the authorization request under its new session. A cancel or an error at the provider brings the browser back to the ceremony page, whose step read makes the `upstream` step current with the error `provider_declined` or `provider_error` and names the provider, beside the other ways in (#1081); the server-rendered pages show a page with a way back to the first step instead. Every other failure shows one error page with the request's trace id. Nothing of either reaches the client. The provider proves the account as a password does, and the sign-in then asks for what the account still owes (ADR-017 Decision 4): an account that holds a passkey gives it after the provider, since a passkey outranks an upstream sign-in, and a recovery code may stand in for it; the realm's MFA policy applies; and where the realm requires verified accounts (`REQUIRE_VERIFIED_ACCOUNT`), an account the provider's verified switch left unverified has its address verified with an emailed code. The Interaction API's ceremony page (`INTERACTION_UI_ENABLED`) shows each of these steps, the passkey while passkey sign-in is on (`WEBAUTHN_ENABLED`). With the Interaction API off, the server-rendered pages show none, so an account that needs one is refused there. With the provider's verified switch off, these pages create no account at all, and neither does the ceremony page for a provider that gives no email: such an account could never sign in. ### Linking an upstream account to an existing one With the Interaction API on, an identity no account holds does not create an account at once (#670). The interaction asks first, at its `upstream_link` step, which `GET /ui/upstream-link/{interactionId}` shows as a page: "I already have an account", or "Create a new account" when the realm allows sign-up. The page names the provider and nothing else: no address, no account and no hint, and QAuth never looks an account up by an email address. "I already have an account" goes on to a sign-in to that account in the same interaction, under its MFA policy: the password or the passkey, then the second factor the account or the realm asks for. No password reset is offered during it. The realm's other upstream providers are, beside its first step, never the one being linked (#1080), so an account made through one provider links a second by signing in with the first. Such a provider proves the account only through an identity the account already holds: one no account holds signs in to nothing and creates nothing, whatever address the provider gives, and a passkey the account holds is still asked for after it. Once that sign-in completes, the identity joins the account it proved, the security log records the link, and the owner gets an email notice at the address of the account's password (an account with no password, such as one made through a provider, has the security log alone); the authorization request goes on as after any sign-in. An identity another account holds, or took meanwhile, or an account that already has an identity of that provider, is refused, and nothing reaches the client: the ceremony page shows the `upstream_link` step again with the reason as its `error` (`already_linked`, `provider_linked`, `account_unavailable`), where the sign-in had finished the interaction as the first step of the request's next one (#1081). The step takes no answer; its cancel ends the request with `access_denied`, or goes back to the account page for a link started there. A signed-in account links a provider through an authorization request with [`qauth_link=`](/integrate/oauth-flow/#linking-an-upstream-account): the provider first, then the same fresh sign-in. The pages of the Interaction API off (`INTERACTION_UI_ENABLED`) offer no link, and create the account at once as before. ## Creating an account from the sign-in With the Interaction API on (`INTERACTION_UI_ENABLED`), the ceremony page's sign-in offers "Create an account" beside its first step (#1079), so a person who opens a sign-in without an account need not go back to the client for a [`prompt=create`](/integrate/oauth-flow/#registration-promptcreate) request. It is offered while the realm allows sign-up (`accounts.allowSignUp`), the client's operator-set `hosted_flows` name `register`, and the authorization request carries no `max_age` and no `prompt=login`: the rules of `prompt=create`, but for the `openid` scope, which only `prompt=create` needs. It is offered before anyone is named and reads no account, so it is the same for every person. Picking it turns the sign-in into the registration `prompt=create` starts, in the same interaction and for the same authorization request: the bot challenge first where the realm or the client requires one, then the address, its mailed code and a new password. The registration ends as one `prompt=create` started: in a code for that request, with no `amr` and no `auth_time`, and no browser session. Until the address is given, the registration's address step offers "I have an account", which leads back to the sign-in's first step; a registration `prompt=create` started offers it too. The rules are read again on every answer: once the realm or the client stops allowing sign-up, picking "Create an account" is refused, and the sign-in stays where it was. A sign-in to [link an upstream account](#linking-an-upstream-account-to-an-existing-one) never offers it, nor does the account page's sign-in. `/ui/login`, the page the Interaction API off serves, offers neither. ## `/ui/resume/{handle}` `GET /ui/resume/{handle}` is where a login round-trip lands after a successful sign-in. It exchanges a single-use handle for the pending `/oauth/authorize` URL and redirects there — see below for what the handle is and why it exists. Resuming re-validates `client_id`, `redirect_uri`, scope, PKCE, and step-up **from scratch**, exactly as on a first visit (`apps/auth-server/src/app/routes/ui/resume.ts:124`); a resumed handle grants nothing a fresh authorize request would not. --- ## The recovery-code pages A recovery code stands in for a lost second factor after the password (#649; ADR-017 Decision 8). Redemption, and showing a new set, happen only on the hosted pages: the sign-in engine never offers either step to the first-party login endpoint, which answers `redirect_to_web` instead. Two server-rendered pages serve them for an interaction of the Interaction API, until the design-system screens of #650 replace them: - `GET /ui/recovery-code/{interactionId}`: "Use a recovery code", one field, with "That code did not work" after a refused one (the same for a wrong code, a used one and one of another set). - `GET /ui/recovery-codes/{interactionId}`: "Save your recovery codes", the ten codes of a new set and a confirmation that they are saved. The codes are shown only to the browser whose flow cookie binds the interaction, and never to a page another site opened (`403`). Both forms post to the Interaction API's answer endpoint (`/interaction/v1-rc.0/{interactionId}/answer`) with the step, its CSRF value and the answer, and work without JavaScript. An interaction at another step is sent to its ceremony page (`303`); one that ended gets a page saying so (`410`). Neither page puts a code in a URL, and both are sent with `Cache-Control: no-store`. A code is `xxxx-xxxx-xxxx-xxxx`, typed with any case, spaces and dashes. Each works once, adds no `amr` value and never meets an MFA policy. Wrong codes are counted on the account, whatever the transaction or the address: from five, each attempt waits longer, up to 15 minutes, after a bot challenge. A code used, a set regenerated and a run of 100 wrong codes (at most once a day) each mail the account a notice that no realm can switch off. --- ## The passkey pages A passkey is the strongest factor an account can bind (#651; ADR-017 Decisions 4 and 10). While passkey sign-in is on, the sign-in engine offers it first: with no `login_hint`, the first step asks for a passkey and offers the password and the identifier beside it; with one, it asks for the password and offers the passkey. A login completes only after the account's strongest bound factor, so after a right password an account that holds a passkey is asked for it. On these pages `/oauth/authorize` starts a sign-in with no `login_hint` at that first step, and one with a hint at the identifier step, which the hint fills in. After a password sign-in, the engine may offer to add one, unless the realm turned that offer off (its `passkeys.offerAfterSignIn` policy, on by default; #1083); a passkey added from the account page is offered either way. Two server-rendered pages run the ceremonies for an interaction of the Interaction API, until the design-system screens of #652 replace them: - `GET /ui/passkey/{interactionId}`: "Sign in with a passkey", for the `webauthn_get` step, current or offered beside the password. Its options name no account. After a refused answer it says "That passkey did not work", the same whatever was wrong. "Sign in another way" links back to the interaction's ceremony page, for the password or the identifier. - `GET /ui/passkey-enrolment/{interactionId}`: "Add a passkey", for the `webauthn_create` step, with "Not now" beside it. The options carry the account's name, so the page is shown only to the browser whose flow cookie binds the interaction, and never to a page another site opened (`403`). Each page runs the browser's ceremony (`navigator.credentials.get()` or `.create()`, so it needs JavaScript) and posts the credential's JSON as `webauthn_response` to the Interaction API's answer endpoint with the step and its CSRF value. The server refuses an assertion whose authenticator did not verify the user. An interaction at another step is sent to its ceremony page (`303`); one that ended gets a page saying so (`410`). Both are sent with `Cache-Control: no-store`. ## The authenticator app's pages An authenticator app (TOTP: RFC 6238 with SHA-1, six digits and a 30-second step, one step of tolerance either way) is a second factor an account binds (#653; ADR-017 Decisions 7 and 8, ADR-020 §3). After the first factor, an account that holds one is asked for its code; under an MFA policy, an account with no second factor is asked to set one up before the sign-in completes, and is sent no code. Password then code gives `amr` `["pwd","otp","mfa"]`. Two server-rendered pages serve the steps for an interaction of the Interaction API, until the design-system screens of #654 replace them: - `GET /ui/totp/{interactionId}`: "Enter the code from your authenticator app", for the `totp` step. After a refused answer it says "That code did not work", the same for a wrong code, a used one and a late one. When the step offers a recovery code in its place, "Use a recovery code instead" opens its form. - `GET /ui/totp-setup/{interactionId}`: "Set up an authenticator app", for the `totp_setup` step: the new secret as a QR code of its `otpauth://` URI and as text, and the field for the first code. The secret is bound only once that code is valid, and that code stands for the sign-in's `totp` step. The secret is personal data, so the page is shown only to the browser whose flow cookie binds the interaction, and never to a page another site opened (`403`). The interaction record does not keep it: each read opens the sign-in's sealed copy again. A verified account binds its first authenticator app after a fresh sign-in, with no code mailed to its address: the account decides, never a channel (ADR-017 Decision 8, amended on 2026-11-08). An unclaimed account, a password account not yet verified, binds none. The first binding issues a set of recovery codes. Both work without JavaScript and post to the Interaction API's answer endpoint with the step and its CSRF value. An interaction at another step is sent to its ceremony page (`303`); one that ended gets a page saying so (`410`). Both are sent with `Cache-Control: no-store`. Wrong codes count per account, across transactions, clients and IP addresses, and no successful password clears them: after the fifth in a row, each attempt waits longer (up to 15 minutes), and an attempt that comes early gets `429` without being checked. A wrong code after a right password mails the account a notice, at most once a day. At 100 in a row, and only while these pages are on and the account keeps an unused recovery code or another usable second factor, the authenticator app is turned off and the account is told. The next sign-in takes a recovery code in its place, then asks to set up the app again before it completes. No realm can switch these notices off. ## The account pages `/ui/account` is the signed-in person's own account (#844; ADR-017 Decision 8, ADR-020 §3): their sign-in methods and their sessions, server-rendered in the style of the pages above until the design-system account console replaces them. The pages exist while the hosted pages do (`INTERACTION_UI_ENABLED`); with the switch off, every path answers `404`. They are reached with the realm's browser session: a browser with none is sent through a sign-in on the hosted pages and comes back. | Page or form | What it does | Replaced by | | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | `GET /ui/account` | The overview: the password, the passkeys, the authenticator app, the recovery codes, the linked upstream accounts and the live sessions, each with its actions | #685 | | `GET` and `POST /ui/account/password` | Change the password, with the current one, under the realm's password policy and breach list | #685 | | `POST /ui/account/passkeys/add`, `…/{passkeyId}/rename`, `…/{passkeyId}/remove` | Add a passkey through the enrolment step of the sign-in, rename one, remove one | #686 | | `POST /ui/account/totp/setup`, `/ui/account/totp/remove` | Set up an authenticator app through the set-up step of the sign-in, turn it off | #687 | | `POST /ui/account/recovery-codes/regenerate`, `…/confirm` | A new set of recovery codes, shown once; every code of the old set stops working | #687 | | `POST /ui/account/sessions/{sessionId}/end`, `/ui/account/sessions/end-all` | Sign one session out, or sign out everywhere | #688 | | `POST /ui/account/linked-accounts/link`, `…/{linkedAccountId}/unlink` | Link an upstream account by signing in to it, unlink one | — | | `POST /ui/account/reauthenticate` | "Confirm it's you": a fresh sign-in, then back to the page | — | Every action is a plain form that works without JavaScript, and every post is checked in the same order before anything is read or changed: it comes from the realm's origin (`Sec-Fetch-Site: same-origin`, or the realm's `Origin` when the browser sends no fetch metadata), it carries the browser session, and it carries the session's CSRF value. A post that fails the first or the last is refused with `403` and recorded as `request.csrf_rejected`. A change asks for the fresh authentication the account API asks of the same operation (#663): a sign-in from the last five minutes with the account's strongest bound factor, a passkey, else the authenticator app, else the password. Removing a passkey or the app also takes a sign-in that used a recovery code in place of the lost one; renaming a passkey and ending sessions ask for none. A change refused for want of one answers `403` with "Confirm it's you", which starts that sign-in and comes back. Adding a passkey, setting up an app and linking an account are such a sign-in themselves, followed by the binding step, with its own rules: a verified account binds its first passkey or app with no mailed code, an unclaimed account binds none, and the first one issues a recovery code set. A link attaches only the identity the provider proved to the account the same interaction signed in to; nothing is matched by an email address. The pages keep an account usable. A removal that would leave no way to sign in (an authenticator app is a second factor, never a way in on its own, and a credential the failure ladder or a passkey rebind turned off counts as none) is refused with `409`, and so is one that would leave no second factor where the realm's MFA policy requires one. A new password ends the account's other sessions and revokes every refresh token; this browser stays signed in. Ending a session, one or all, tells every relying party it signed in to with a back-channel logout URI. Each change is recorded as a user event: `user.password_changed`, `user.credential_added`, `user.credential_removed`, `user.passkey_renamed`, `user.recovery_codes_regenerated`, `user.upstream_linked`, `user.upstream_unlinked` and `user.signed_out`. A changed password, a removed passkey or authenticator app, a new recovery code set and a linked or unlinked account mail the owner. No page puts a secret or a code in a URL, and every page is sent with `Cache-Control: no-store`. ## Pending authorization: why the login bounce carries a handle, not a URL Before qauth-labs/qauth#319, every surface that bounced an unauthenticated user to `/ui/login` nested the **entire** authorize query string inside `return_to` — `/ui/login?return_to=%2Foauth%2Fauthorize%3F...`. Once `state` and `nonce` could legally reach 2048 characters each (`OAUTH_OPAQUE_PARAM_MAX_LENGTH`, `apps/auth-server/src/app/constants/security.ts:71`), that `Location` header could exceed ~10 KB — past a typical reverse proxy's default header-buffer limit, so the browser got a `414`/`400` instead of the login page, on the _unauthenticated first-visit path_, and invisibly so in local dev (no proxy in front). #319 fixed this by parking the authorize URL server-side instead of nesting it in the redirect: - An unauthenticated `GET /oauth/authorize` no longer nests the query. It stashes the normalized authorize URL in Redis under a fresh, **43-character CSPRNG handle** (`randomBytes(32).toString('base64url')`, `apps/auth-server/src/app/helpers/pending-authorization.ts:191`) and redirects to `/ui/login?return_to=%2Fui%2Fresume%2F` — a ~60-byte path regardless of how large `state`, `nonce`, or `resource` are. The same stash-then-redirect helper (`redirectToLoginWithPendingAuthorization`, `apps/auth-server/src/app/helpers/pending-authorization.ts:283`) backs all five login-bounce call sites: two in `apps/auth-server/src/app/routes/oauth/authorize.ts` (lines 334 and 554) and three in `apps/auth-server/src/app/routes/ui/consent.ts` (lines 369, 559, and 763) — #319 closed #316 specifically because an earlier fix had patched only one of these sites and missed the rest. - **Single-use.** `consumePendingAuthorization` (`apps/auth-server/src/app/helpers/pending-authorization.ts:235`) deletes the Redis record as it reads it, before even validating its contents. A login URL saved from browser history or a screen recording therefore cannot be replayed — the second `GET /ui/resume/{handle}` for the same handle gets the "expired" page, not a second redirect. - **Expires after 10 minutes.** `PENDING_AUTHORIZATION_TTL_MS` (`apps/auth-server/src/app/constants/security.ts:101`) bounds the stash to 10 minutes — long enough to cover an interactive login (reading the page, a password manager, typing credentials) but short because this is auth-flow state, not user session state. An expired, unknown, or already-used handle all render the **same** "this sign-in request has expired" page (`expiredPage`, `apps/auth-server/src/app/routes/ui/resume.ts:40`) with a plain `400` and no redirect — the three cases are made indistinguishable on purpose, so a handle can't be probed to learn which ones ever existed. - **A `state` over 16 KB is rejected outright.** `PENDING_AUTHORIZATION_MAX_URL_BYTES` (`apps/auth-server/src/app/constants/security.ts:131`) caps the authorize URL the stash will accept. This is a backstop behind the per-parameter 2048-character bounds already enforced at the edge, not the primary control — but because the stash is written **before** the caller authenticates, an unbounded write there would be a pre-auth memory-pressure primitive against the same Redis that holds live browser sessions and rate-limit counters. Exceeding it returns `400 invalid_request: authorization request is too large` (`apps/auth-server/src/app/helpers/pending-authorization.ts:215`) rather than either a `500` or a `Location` header no proxy would forward. ### What the stash adds to the pre-authentication path Redis was already on that path before #319, and not marginally: the Redis-backed `@fastify/rate-limit` plugin is registered app-wide (`apps/auth-server/src/app/app.ts:931`) ahead of both route sweeps (lines 294 and 301), against the same `fastify.redis` client (`apps/auth-server/src/app/plugins/rate-limit.ts:28`); `RATE_LIMIT_ENABLED` defaults to `true` (`libs/server/config/src/lib/schemas/rate-limit.ts:41-44`); and the `onRequest` hook it attaches to each of those routes `INCR`s a per-IP counter on **every** request, authenticated or not (`PEXPIRE` on the first request of a window, `PTTL` on the rest) — which is why the size cap above talks about "the same Redis that holds live browser sessions and rate-limit counters". What the pending-authorization stash adds is narrower and worth naming precisely: it is the first **caller-influenced write** an unauthenticated visitor can trigger, which is the reason `PENDING_AUTHORIZATION_MAX_URL_BYTES` exists at all. The stash itself degrades rather than fails. If the write cannot complete, `redirectToLoginWithPendingAuthorization` catches the failure and falls back to the pre-#319 inline `return_to` (`apps/auth-server/src/app/helpers/pending-authorization.ts:299`). That inline fallback is correct for every request small enough to survive a proxy's header buffer — only the pathological multi-kilobyte `state` that motivated the stash would still be too large, and for that one case an oversized redirect is judged better than an opaque `500` at the authorization endpoint. Symmetrically, a **read** failure in `consumePendingAuthorization` is treated as a miss and renders the same "expired" page a genuinely-expired handle would (`apps/auth-server/src/app/helpers/pending-authorization.ts:245`) — an unreachable Redis is, from the user's side of `/ui/resume/{handle}`, indistinguishable from a handle that timed out. Do not read those fallbacks as "a Redis outage is invisible here", though. `@fastify/rate-limit`'s `skipOnError` is left at its default of `false`, so when the store is unreachable the rate-limiter rethrows from its `onRequest` hook — before the route handler, and therefore before either fallback can run. The fallbacks cover a stash operation that fails on its own; they do not cover an instance-wide Redis outage, which fails the request at the rate-limit hook instead. ### Not an open redirector The handle is never resolved from anything the client controls at resume time — it comes back out of Redis and is re-validated (`normalizeInternalAuthorizeUrl`, `apps/auth-server/src/app/helpers/pending-authorization.ts:153`) to be this server's own `/oauth/authorize` path before use, both on the way in and the way out. The only reachable destination is a fresh visit to `/oauth/authorize`, which then re-checks `client_id` and `redirect_uri` exactly as it would on a first request. --- ## See also - [OAuth 2.1 Flow](/integrate/oauth-flow/) — the `authorization_code` + PKCE walkthrough these screens sit inside. - [API Reference → Hosted UI](/integrate/api-reference/#hosted-ui) — the wire contract (methods, parameters, status codes) for these routes. --- # API Reference URL: https://docs.qauth.dev/integrate/api-reference/ Hand-written reference for QAuth's HTTP endpoints. The **authoritative, always-current** contract is the interactive OpenAPI / Swagger UI served at /docs on any running instance — this page is a stable, linkable companion. The same contract is also published as a static file: [`openapi.json`](https://docs.qauth.dev/openapi.json). For step-by-step flows with copy-paste `curl`, see the [OAuth 2.1 Flow](/integrate/oauth-flow/) guide; for working client code, see [Code Examples](/integrate/code-examples/); for the login/consent/resume screens an end user sees, see [Hosted UI](/integrate/hosted-ui/). **Conventions** - Base URL / issuer: `http://localhost:3000` (your `JWT_ISSUER`). - First-party auth, client-management, and hosted-UI bodies are **JSON** (`application/json`) with **camelCase** fields. OAuth wire endpoints (the OAuth 2.1 section below) use **`application/x-www-form-urlencoded`** with **snake_case** per the RFCs. - Access tokens are **EdDSA (Ed25519)** JWTs; verify against `GET /.well-known/jwks.json`. - Path parameters are written in OpenAPI's `{param}` style below (matching `openapi.json`), not the `:param` style the Fastify route files use internally. ## Error model QAuth answers an error in one of two formats (ADR-019 Decision 7). Branch on the machine-readable member and the HTTP status, never on the wording, which may change. **Problem documents.** Every route that is not an OAuth protocol endpoint (first-party auth, client management, consents, the hosted UI, the well-known documents and any path no route serves) answers with an RFC 9457 problem details document, sent as `application/problem+json`: ```json { "type": "https://docs.qauth.dev/reference/problem-types/#validation-error", "title": "Validation error", "status": 400, "detail": "The request is not valid.", "instance": "/auth/login", "code": "validation_error", "trace_id": "7d1c2e9a-5b8f-4f3e-9c6a-1e2d3f4a5b6c", "errors": [{ "pointer": "/email", "message": "Invalid email address" }] } ``` - `code` is the stable, machine-readable name of the error. The list of codes, with each one's `type`, status and title, is on [Problem types](/reference/problem-types/). - `trace_id` is the id the server logged the request under, the `X-Request-Id` the response also carries (the header `REQUEST_ID_HEADER` names), on every answer, the 404 of a path no route serves and a request the server could not read included. Quote it when you report a failure. - `errors` names each field a validation failure rejected: `pointer` is an RFC 6901 JSON Pointer into the body or the parameters, and `message` says what is wrong with it. The validator's own issue objects also carry the schema path, the rule keyword and a parameter bag, and those describe how QAuth is built rather than what it accepts, so they are not returned. At most 20 entries are returned. An array longer than its limit is one entry for the array as a whole; its elements are not checked. A weak password's feedback arrives as entries whose pointer names the password field. - A 5xx carries no `detail`. A 401 or 403 from a route that takes a bearer token also carries a `WWW-Authenticate` challenge (RFC 6750 §3): `error="invalid_token"` for a token that was presented and refused (`invalid_access_token`, `access_token_expired`), and no error code for a request that presented none (`unauthorized`). **OAuth error bodies.** The OAuth protocol endpoints answer with the body their specification defines, `application/json` with only `error` and, where there is something to say, `error_description`: the authorization, token, introspection, revocation, dynamic registration and userinfo endpoints and the wallet-facing OID4VP endpoints. ```json { "error": "invalid_client", "error_description": "CIMD document is not valid JSON" } ``` `error` is the registered code (RFC 6749 §5.2, RFC 7591 §3.2.2, RFC 6750 §3.1); the codes are listed in [OAuth 2.1 Flow → Errors](/integrate/oauth-flow/#errors). `error_description` is omitted where describing the failure would be an enumeration oracle: client authentication failures answer with the bare `invalid_client`. The `Retry-After` and `WWW-Authenticate` headers are sent as before. | Status | Meaning | | ------ | --------------------------------------------------------------------------------------------------- | | `400` | Malformed request / validation error | | `401` | Missing, malformed, or invalid bearer token / session | | `403` | Authenticated but not permitted (e.g. insufficient scope) | | `404` | Resource not found | | `409` | Conflict (e.g. a resource that must be unique) | | `429` | Rate limited | | `503` | Too busy to check a client secret now; `Retry-After` says when to retry (`temporarily_unavailable`) | --- ## System Unauthenticated operational endpoints. ### `GET /` Root endpoint. Returns a fixed greeting to verify the server is running. **`200 OK`**: `{ "message": "Hello API" }` ### `GET /health` Liveness probe. Answers on any host, checks no dependency, and names the service roles the process runs. **`200 OK`**: ```json { "status": "ok", "timestamp": "2026-07-27T00:00:00.000Z", "roles": ["web", "api", "admin", "signer", "delivery"] } ``` ### `GET /ready` Readiness probe. Answers on any host and runs the checks the process's roles need: `database` on every role, `redis` on `web`, `api` and `admin`, `kek` on `signer` and `outbox` on `delivery`. **`200 OK`** (every check passed): ```json { "status": "ok", "roles": ["api"], "checks": { "database": { "status": "ok" }, "redis": { "status": "ok" } } } ``` **`503 Service Unavailable`**: a problem document with the code `service_unavailable`, whose `detail` names the failed checks and whose extension members `roles` and `checks` carry what the `200` answer carries, each failed check as `{ "status": "fail", "reason": "error" }` or `"reason": "timeout"`. Both are rate-limited per client address. The Prometheus metrics are not on this port: they are served on the internal listener (see [Observability](/operate/observability/#who-can-read-the-metrics)). --- ## First-party authentication Email/password endpoints for **end users of your own application**. Third-party / MCP clients use the [OAuth endpoints](#oauth-21) instead. ### `POST /auth/register` Register a password account. The answer is the same for every address, whether it was free or already has an account, so it reveals neither. What happens next arrives by email (the `mock` provider delivers nothing): - A free address gets an unverified account and a verification link. - An address that already has a verified account gets no new account. Its owner gets a notice that someone tried to register it, with no link in it. - An address whose account is still unverified gets its verification link again. The link verifies only with the password chosen when the account was first registered (see [`POST /auth/verify`](#post-authverify)). A mail to an existing account counts toward the same per-address caps as [`POST /auth/resend-verification`](#post-authresend-verification), and every registration claims the minimum interval there, so a resend right after a registration answers `429` for any address. A new account's verification mail is always sent. **Request** (`application/json`) | Field | Type | Required | Notes | | ---------- | -------------- | -------- | -------------------------------------------- | | `email` | string (email) | yes | At most 254 characters (RFC 5321). | | `password` | string | yes | The realm's password policy applies (below). | | `realmId` | string (uuid) | no | Must be the realm of the request's host. | **`202 Accepted`** (`Cache-Control: no-store`) ```json { "message": "Registration received. Check your inbox for an email with the next step." } ``` The body carries no user id: anything that differed between a new account and an existing one would answer the question this endpoint must not. Show a "check your inbox" screen, never "account created" or "address taken". While the realm requires verified accounts (`accounts.requireVerifiedAccount`, the default), the account can sign in only after its owner has verified it; until then [`POST /auth/login`](#post-authlogin) answers it with the same `401` as a wrong password. The account is created in the realm of the host the request names. A `realmId` in the body picks no other realm: any value but that realm's id is refused. The password must meet the realm's password policy (ADR-017 Decision 8): at least `password.minLength` characters (15 by default), counted after Unicode NFC normalisation; at most 256 UTF-16 code units as typed; not, as a whole and ignoring case, a common word or password, the address, its local part or domain, or the service's or realm's name; and a zxcvbn score of at least `password.minScore` (2 by default). No mix of character types is required. Mark the fields for password managers: `autocomplete="username"` on the address and `autocomplete="new-password"` on the password. Errors: `400` (validation, a password over 256 UTF-16 code units among it, or a `realmId` that is not the host's realm), `422` (`weak_password`, whose `reasons` list why the password was refused: `too_short`, `blocklisted`, `too_weak`; `errors` carries a message for each, whose pointer names the password field), `429` (rate limited). None of them depends on whether the address is registered. Before 2026-10-19 this endpoint answered `201` with the new user and `409` for a registered address (breaking change, listed in `CHANGELOG.md`). ### `POST /auth/login` Authenticate with email/password and receive tokens directly (first-party). **Request** (`application/json`): `{ "email": "...", "password": "..." }`. The address is at most 254 characters. **`200 OK`** ```json { "access_token": "eyJ…", "refresh_token": "a1b2…(64 hex)", "expires_in": 900, "token_type": "Bearer" } ``` The access token is issued to the system client, its `aud` is the developer API's audience, `urn:qauth:aud:developer:v1-rc.0` (#524), and its `scope` is `qauth:developer:read qauth:developer:write` (#933): it reaches the [Developer API](#developer-api-developer-family-v1-rc0), the deprecated `/api/clients/` and `/api/consents/`, userinfo and `POST /auth/logout`. A token minted before #524, whose `aud` was the system client's id, is refused there, so its developer signs in again (see [API conventions](/reference/api-conventions/#issuing-a-family-audience)). It is not a sign-in at `GET /oauth/authorize`, which takes only the browser session. Errors: `400` (validation), `401` (invalid credentials), `429` (rate limited, locked out, or inside the account's wait on the [per-account failure ladder](/operate/observability/#per-account-failure-ladder); with `Retry-After`), `503` (the failure ladder's store is unavailable; nothing was checked; `Retry-After: 5`). The `401` is the same for an unknown address, a wrong password, a disabled account and, while the realm requires verified accounts (`accounts.requireVerifiedAccount`, the default), an account whose address is not verified yet: registering an address and then logging in must not tell a free address from a registered one. Renew the access token with the [`refresh_token` grant](/integrate/oauth-flow/#refresh-token-rotation) at `POST /oauth/token` — there is no separate refresh endpoint. ### `POST /auth/logout` Sign the user out everywhere. Ends the user's hosted browser sessions (from `/ui/login` or wallet login, so `/oauth/authorize` shows the login page again), revokes every refresh token the user holds, at every client, invalidates the user's unredeemed authorization codes, and denylists the access token the request was made with. Access tokens already issued to other clients are not reached: they stay valid until they expire (see [what revocation reaches](/integrate/oauth-flow/#what-revocation-reaches)). **Headers**: `Authorization: Bearer ` (required). Only the access token from [`POST /auth/login`](#post-authlogin) is accepted, expired or not. A token issued to any other client or audience is refused. **`200 OK`**: `{ "success": true, "message": "Successfully logged out" }` Errors: `401` (missing/invalid bearer, or a token whose user no longer exists). ### `POST /auth/verify` Confirm an email address with the token from the verification email and the account's password. The token proves control of the mailbox. The password proves the person who registered. Both are required, so someone who registered with another person's address cannot get it verified by that person clicking a link. This is a POST, so no fetch or prefetch of the emailed link can verify an address by itself. The emailed link opens [`GET /auth/verify`](#get-authverify), a page that asks for the password; the developer portal's verification page calls this route the same way. **Body**: `{ "token": "<64-char hex string>", "password": "" }` **`200 OK`**: `{ "message": "...", "email": "dev@example.com" }` Errors: `400` (malformed token, or an unknown, expired or used token: one answer for all of them), `401` `INVALID_CREDENTIALS` (wrong password; the token is not consumed, so the request can be retried), `409` (the address is already verified), `429` (rate limited, or the account is locked out: wrong passwords here count toward the same failed-login lockout and per-account failure ladder as `POST /auth/login`, and the `429` carries `Retry-After`), `503` (the failure ladder's store is unavailable; the password was not checked; `Retry-After: 5`). ### `GET /auth/verify` The page the verification email links to: `/auth/verify?token=<64-char hex>`, where `` is the issuer of the account's realm. The link verifies only at a host of that realm. The GET does not look the token up, so at another realm's host it still renders the form (`200`); submitting it there answers `400`, as for an unknown token, because `POST /ui/verify` looks the token up in the realm the request's host names. A mail client opens the link with GET, so this page is what a person sees. It holds the token in a hidden field and asks for the account's password; opening it verifies nothing and reads nothing from the database, so a mail gateway's link scanner or a browser prefetch cannot verify an address. The form posts to `POST /ui/verify` (below), which verifies through the same code as [`POST /auth/verify`](#post-authverify). **Query**: `token` — the 64-character hex token. A missing or malformed token gets a `400` page that says the link is incomplete, with no form. **`200 OK`**: `text/html` page with the form. It sets the signed `__Host-qauth_login_csrf` cookie the form's double-submit CSRF check needs, the same cookie as [`/ui/login`](/integrate/hosted-ui/#uilogin). Every answer carries `Cache-Control: no-store` and `Referrer-Policy: no-referrer`, and the request log censors the `token` value. A `browser` route: it answers on the realm's primary host only (`421` on an endpoint host). **`POST /ui/verify`** (`application/x-www-form-urlencoded`: `token`, `password`, `csrf_token`) answers a page with the status `POST /auth/verify` answers for the same outcome: | Status | Outcome | | ------ | ---------------------------------------------------------------------------------------------- | | `200` | Verified. The account can sign in. | | `400` | Unknown, expired or used token (one page for all), or a malformed body. | | `401` | Wrong password. The form stays; nothing is consumed. | | `403` | CSRF pair missing or mismatched. The form comes back with a fresh token. | | `409` | The address is already verified. | | `429` | Rate limited, or locked out by the failed-login lockout or the failure ladder (`Retry-After`). | | `503` | The failure ladder's store is unavailable; nothing was checked (`Retry-After`). | It has the rate limit of `POST /auth/verify`, and wrong passwords count toward the same failed-login lockout and failure ladder. This page is interim: the email-code step (#638) and the hosted account verification UI (#644) replace link verification. ### `POST /auth/resend-verification` Re-send the verification email. Rate-limited per address (min-interval + per-window caps). [`POST /auth/register`](#post-authregister) counts toward the same caps. **Request** (`application/json`): `{ "email": "dev@example.com" }` **`200 OK`**: `{ "message": "..." }` — returned even for unknown addresses (no account enumeration). Errors: `400` (not an address, or longer than 254 characters), `429` (too soon / over limit). ### `POST /auth/link/wallet` Start linking a wallet credential to the **signed-in** account (issue #238, [ADR-004](/reference/records/adr/004-wallet-agnostic-federation/) / [ADR-009](/reference/records/adr/009-wallet-account-resolution/) §5). Registered only when `WALLET_FEDERATION_ENABLED` is on; otherwise the path does not exist (`404`). Requires a valid `__Host-qauth_session` cookie **and** an `X-CSRF-Token` header matching the per-session token returned by `GET /consents/`. **Request** (`application/json`, optional): `{ "device": "this" }`. Linking is **same-device only** ([ADR-013](/reference/records/adr/013-same-device-return-leg/), amended 2026-11-07): the wallet must be on the device the browser runs on. Render `invocation_uri` as a deep link; the link completes on the browser's return leg at `GET /ui/wallet-login/return`, never by polling. `this`, an empty body, or no body at all are the same request. `"device": "other"`, the cross-device mode this endpoint accepted before, is refused with `400` `validation_error`, whose `errors` entry points at the `device` member, as is any other value. The generated OpenAPI document marks this request body as required — the generator's default for any declared body schema — but a `POST` with no body at all is accepted. **`200 OK`**: `{ "handle": "...", "invocation_uri": "openid4vp://...", "expires_at": 1730000000000 }` — render `invocation_uri` as a deep link ("Open my wallet"); it is opaque. Errors: `401` (no session), `400` (`invalid_csrf_token`, or a `device` other than `this`: `validation_error`), `404` (no usable `VerifierProfile`), `500`. ### `GET /auth/link/wallet/{handle}` Poll a linking flow, and complete it once the wallet has responded. Requires the session cookie and the browser-binder cookie minted by the `POST` above; the session must be the **same user** that started the flow. **`200 OK`**: `{ "status": "pending" | "linked" | "conflict" | "expired" | "rejected", "message"?: "..." }` On `linked`, a second `user_credentials` row (`provider_type='wallet'`) now exists under the same `users.id`, keyed on the identifier the account already owned — so a later wallet sign-in returns tokens with the identical `sub`. This poll never completes a link: it answers `pending` after the wallet has responded, `linked` **once** after the return leg has completed it in the same browser, and `rejected` if the redirect has not landed within 180 s or landed in a different browser (#405). A cross-device link flow started before the 2026-11-07 change is answered `rejected`, and nothing is linked. `conflict` is the one specific outcome (the credential belongs to another account) and is only reachable under the `issuer-scoped-claim` strategy; every other failure renders one uniform refusal. See [Wallet sign-in](/integrate/wallet-login/#account-linking-238). ### `GET /auth/pass` The page a mailed pass link opens: `/auth/pass?pass=<43-char base64url>`, where `` is the issuer of the account's realm. The send-ceiling notice and the password-disabled notice carry it. The pass is single use, bound to the address it was mailed to and valid 24 hours. It takes one reset or registration start on the hosted pages past every per-address send cap; the code that start mails is still required. Opening the link does not spend the pass: it starts an interaction in this browser, with a flow cookie of its own, that holds the pass's hash, and answers `303` to the interaction's ceremony page, whose path names the interaction id and nothing else. Only the start the interaction carries spends the pass, so a mail gateway's link scanner or a browser prefetch spends nothing. A reset starts at the `password_reset` step, a registration at the `identifier` step. Until the hosted reset and registration are built, the interaction can be read but not answered. **Query**: `pass` — the mailed pass. A missing, malformed, unknown, used or expired pass, or one of another realm, gets one `400` HTML page and no interaction. Every answer carries `Cache-Control: no-store` and `Referrer-Policy: no-referrer`, and the request log censors the `pass` value. --- ## OAuth 2.1 Full request/response detail and a worked end-to-end walkthrough live in the [OAuth 2.1 Flow](/integrate/oauth-flow/) guide. Contract summary: | Endpoint | Method | Body type | Purpose | | ---------------------------------------------------------------------------------- | --------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | [`/oauth/authorize`](/integrate/oauth-flow/#2-redirect-the-user-to-oauthauthorize) | GET, POST | query / form | Start `authorization_code` + PKCE. POST mirrors GET with form-encoded params (OIDC Core §3.1.2.1) — browser-navigated either way, not an API call. | | [`/oauth/token`](/integrate/oauth-flow/#3-exchange-the-code-for-tokens) | POST | form | `authorization_code` / `refresh_token` / `client_credentials` / `token-exchange` (RFC 8693) | | [`/oauth/introspect`](/integrate/oauth-flow/#token-introspection-rfc-7662) | POST | form | Token introspection (RFC 7662) — confidential clients only | | [`/oauth/userinfo`](/integrate/oauth-flow/#userinfo-oidc) | GET, POST | — | OIDC UserInfo (Bearer header, or POST with a form-encoded `access_token`, RFC 6750 §2.2) | | [`/oauth/register`](/integrate/oauth-flow/#dynamic-client-registration-rfc-7591) | POST | JSON | Dynamic Client Registration (RFC 7591, open mode) | | `/oauth/revoke` | POST | form | Token revocation (RFC 7009) | | [`/oauth/end-session`](/integrate/oauth-flow/#rp-initiated-logout) | GET, POST | query / form | RP-initiated logout (OpenID Connect RP-Initiated Logout 1.0) — browser-navigated, not an API call | Key contract facts: - `response_type` is `code` only; `code_challenge_method` is `S256` only (PKCE required). - Tokens carry `iss`, `aud` (RFC 8707 resource binding), `exp`, `iat`, and `scope`. - `client_credentials` tokens set `sub = client_id` and issue **no** refresh token. - Scopes are **deny-by-default** (client allowlist; DCR clients capped to the realm's allowlist, which `DEFAULT_DYNAMIC_REGISTRATION_SCOPES` seeds). The `qauth:` scope namespace is QAuth's own, the admin API's scopes among them: no realm allowlist holds one, so dynamic registration drops one, a CIMD document's is ignored, and discovery does not list them ([admin permissions](/reference/admin-permissions/)). - The `urn:ietf:params:oauth:grant-type:token-exchange` grant (RFC 8693, ADR-007 §2) lets an **agent** client delegate on behalf of a user: `sub` stays the user and an `act` claim names the agent (nested for chained delegation). Agent-only and default-deny; scope/audience are preserved or narrowed, never widened. See the [Token Exchange](/integrate/oauth-flow/#token-exchange--agent-on-behalf-of-delegation-rfc-8693) section and the [Agent Authorization guide](/integrate/agent-authorization/). - DPoP (RFC 9449), when the operator sets `DPOP_ENABLED=true`: a token request that carries a `DPoP` proof gets an access token bound to the proof's key (`cnf.jkt` in the token, `token_type: "DPoP"` in the response), whatever the grant. Send `dpop_jkt` on the authorization request to bind the code to the key too. A public client's refresh token is bound to the key and needs a proof under it on every refresh; a confidential client's is not. A client the operator marked `dpop_bound_access_tokens` in the seed manifest must send a proof on every token request, or gets `400 invalid_dpop_proof`. Discovery then lists the accepted proof algorithms in `dpop_signing_alg_values_supported`. A resource server built on `@qauth-labs/resource-guard` refuses a bound token, because the SDK verifies no DPoP proof yet; a client that calls one sends no proof, so its tokens stay unbound. Three kinds of token are always bound, so they cannot be used at such a resource server until the SDK verifies DPoP proofs: the tokens of a client marked `dpop_bound_access_tokens`, a token exchanged from a DPoP-bound subject token, and an access token issued for a `cnf`-bound ID-JAG. - Dynamic Client Registration (`POST /oauth/register`) accepts the optional QAuth extension field `is_agent` (boolean, default `false`) marking the client as an AI agent; it is echoed back in the response. The flag is self-asserted and untrusted — see [Agent Authorization](/integrate/agent-authorization/#1-agent-client-type-is_agent). ### Token response (`POST /oauth/token`, `200 OK`) ```json { "access_token": "eyJ…", "refresh_token": "a1b2…", // omitted for client_credentials, token-exchange, a client whose grant_types leave out refresh_token, and a code no stored consent covers (its scopes at each resource) "id_token": "eyJ…", // authorization_code grant only, when `openid` was granted "expires_in": 900, "token_type": "Bearer", "scope": "openid profile email", // present when scopes granted "issued_token_type": "urn:ietf:params:oauth:token-type:access_token" // token-exchange only (RFC 8693 §2.2.1) } ``` `id_token` is a separate, client-audienced (`aud` = your `client_id`) EdDSA JWT asserting the sign-in event: `sub`, `nonce` (when sent), `auth_time` (when a sign-in backs the code), `amr` (when that sign-in recorded its methods), `sid` (the browser session, when one backs the code), `name` (only under the `profile` scope, and only when set on the user), and `email` / `email_verified` (only under the `email` scope, same trust-ordered resolution as the [UserInfo response](#userinfo-response-getpost-oauthuserinfo-200-ok) below). The ID token and UserInfo release the same profile and email claims for the same grant. Only the `authorization_code` grant issues one; `refresh_token` does not reissue it. See [ID token claims](/integrate/oauth-flow/#id-token-claims-oidc) for the full list. ### Introspection response (`POST /oauth/introspect`, `200 OK`) ```json { "active": true, "sub": "...", "client_id": "...", "scope": "mcp:read", "aud": "http://localhost:8088", "iss": "http://localhost:3000", "exp": 1750000000, "iat": 1749999100, "token_type": "Bearer" } ``` An inactive/expired/unknown/wrong-audience token returns `{ "active": false }`, and so does a user's token once that user is disabled or deleted. A DPoP-bound token is reported with `"token_type": "DPoP"` and `"cnf": { "jkt": "…" }`, the thumbprint of its key (RFC 9449 §6.2): the resource server checks the request's DPoP proof against it. `@qauth-labs/resource-guard` checks no DPoP proof yet, so it refuses such a token with `401 invalid_token`. ### UserInfo response (`GET|POST /oauth/userinfo`, `200 OK`) ```json { "sub": "...", "email": "dev@example.com", "email_verified": true } ``` `email`/`email_verified` are conditional: released only under the `email` scope and only when a **verified** email attribute exists (ADR-002 trust order `wallet > oidc_* > self_reported`). Otherwise both keys are **absent** (never `null`). When present, `email_verified` is always `true`. The `POST` form accepts the access token either as a Bearer header or as a form-encoded `access_token` field (RFC 6750 §2.2). A token whose user has been disabled since it was issued gets `401`. So does a token that is not addressed to UserInfo: its `aud` must contain the token's own `client_id` (the default when the grant named no `resource`) or the `userinfo_endpoint` URL from discovery. See [UserInfo](/integrate/oauth-flow/#userinfo-oidc). A DPoP-bound token is accepted only as `Authorization: DPoP ` with a `DPoP` proof for the request whose `ath` is the token's hash (RFC 9449 §7.1). Sent as a Bearer token, in the header or the form body, it gets `401` (RFC 9449 §7.2). ### `POST /oauth/revoke` RFC 7009 token revocation. Confidential client authentication required (`client_secret_basic` / `client_secret_post`, the same as introspection). **Request** (form-urlencoded): `token` (required), `token_type_hint` (`access_token` | `refresh_token`, optional/advisory), plus client credentials if not sent via HTTP Basic. **`200 OK`**, empty body — **always**, per RFC 7009 §2.2. A refresh token revokes its whole rotation family; an access token is denylisted by `jti` for its remaining lifetime. A token the caller doesn't own, or an unknown/invalid token, is a silent no-op (not an error), so the endpoint cannot be used to probe whether a token exists. The only non-`200` outcomes are a client authentication failure (`invalid_client`) and `503` with `Retry-After` when the server is too busy to check the client secret (`temporarily_unavailable`; RFC 7009 §2.2.1: the token still exists, retry after the delay). ### `GET|POST /oauth/end-session` OpenID Connect RP-Initiated Logout 1.0. A browser route: the app sends the browser here. **Parameters** (query, or form-encoded body): `id_token_hint`, `client_id`, `post_logout_redirect_uri`, `state`, `ui_locales` and `logout_hint`, each optional and given at most once. - A valid `id_token_hint` (expired accepted) that names the account of the browser session and, by its `sid`, that session ends it: **`303`** to the registered `post_logout_redirect_uri` with `state`, or **`200`** with a signed-out page, and the session cookie cleared. - Any other signed-in browser gets the `logout_confirm` step: **`200`** with a form, or **`303`** to the ceremony page when `INTERACTION_UI_ENABLED` is on. Nothing ends until the person answers. - A browser with no session: **`303`** to the registered URI, or **`200`**. - A client the operator did not set up (open dynamic registration, CIMD, the developer API) gets no automatic redirect in either case: **`200`** with the signed-out page, which links to the registered URI with `state`. - **`400`** HTML information page, never a redirect: `client_id` is not the hint's audience, a `post_logout_redirect_uri` comes with no client named, or the client does not register it exactly, or the client is disabled. - A `POST` without the session cookie (the cookie is `SameSite=Lax`, so a cross-site form post leaves it out): **`303`** to the same endpoint as a `GET` with the same parameters. - **`400`** `validation_error` for a repeated or over-long parameter; **`429`** over the authorization endpoint's rate limit. No refresh token is revoked. `GET /oauth/end-session/status` takes no parameter and says whether this browser is still signed in. --- ## Discovery Unauthenticated and cacheable. The discovery documents carry `Cache-Control: public, max-age=3600`; the JWKS carries `public, max-age=300`, so an HTTP cache keeps a key set for at most five minutes. A JWKS client library keeps its own cache: set it to five minutes at most ([Caching the JWKS](/integrate/oauth-flow/#caching-the-jwks)). | Endpoint | Returns | | --------------------------------------------- | ---------------------------------------------------------- | | `GET /.well-known/oauth-authorization-server` | OAuth 2.0 AS metadata (RFC 8414) | | `GET /.well-known/openid-configuration` | OIDC Discovery 1.0 (superset of the above) | | `GET /.well-known/jwks.json` | JWKS — the realm's published token signing keys (RFC 7517) | All three are the documents of the realm the request's host names ([ADR-019](/reference/records/adr/019-deployment-topology-and-trust-boundaries/), Decision 1): - `issuer` is the realm's issuer, and every endpoint URL, `jwks_uri` included, is built on it. - The JWKS lists the realm's published keys, each under its own `kid`: its active keys, a next key from its publication, before it signs, and a retired key while it stays published (after a rotation, until its retention ends). No other realm's key is in it. - `id_token_signing_alg_values_supported` follows the realm's active keys: `["RS256","EdDSA"]` when it has an active RS256 key, else `["EdDSA"]`. - `client_id_metadata_document_supported` follows the realm's CIMD policy (`registration.cimd.enabled`, [#534](https://github.com/qauth-labs/qauth/issues/534)): a realm that has CIMD off omits it. - The settings that add the other members belong to the deployment, and every realm advertises them alike: `DPOP_ENABLED`, and `ID_JAG_ENABLED` once `ID_JAG_TRUSTED_ISSUERS` names an issuer. Every node that serves these documents (a process holding the `api` role, see [Service roles](/operate/service-roles/)) answers the same bytes for the same realm, whether or not it holds the signer, given the same settings. After a key changes, a node may serve its previous key list for up to a minute, its key refresh interval. A host that names no realm gets `421` for all three, with no fallback to the default realm. An endpoint host (`DEFAULT_REALM_ENDPOINT_HOSTS`) gets its realm's documents unchanged: `issuer` and every URL name the realm's issuer, never the endpoint host, and no document lists endpoint hosts. RFC 8414 §3.3 and OpenID Connect Discovery §4.3 ask a client to check that `issuer` is the issuer whose well-known URL it fetched, which an endpoint host's URL is not. A server-side caller that reaches QAuth through an endpoint host must therefore configure the realm's issuer explicitly, check `issuer` and the tokens' `iss` against it, and use the endpoint host only as the address it connects to. A client library that takes the expected issuer from the URL it fetched refuses these documents. Prefer discovering endpoint URLs from these documents over hard-coding paths. The AS metadata advertises `resource_indicators_supported: true`, `authorization_response_iss_parameter_supported: true` (RFC 9207 — `/oauth/authorize` returns `iss` on both success and error redirects, so the flag is not configurable), and, when the realm's CIMD policy is on, `client_id_metadata_document_supported: true` (CIMD). ### Passkey association files Experimental. Two more well-known files tell Apple and Android platforms which native apps share the realm host's passkeys and passwords. They are served only while the passkey RP ID is the realm host (`WEBAUTHN_RP_ID` unset or equal to it), and only for a platform the operator listed apps for; otherwise the path answers `404`. Both carry `Content-Type: application/json` and `Cache-Control: public, max-age=3600`. | Endpoint | Returns | | --------------------------------------------- | --------------------------------------------------------------------------------------------- | | `GET /.well-known/apple-app-site-association` | `webcredentials.apps` from `WEBAUTHN_APPLE_APP_IDS` | | `GET /.well-known/assetlinks.json` | One `delegate_permission/common.get_login_creds` statement per app in `WEBAUTHN_ANDROID_APPS` | ### Shared Signals Framework transmitter Experimental, off by default. With `SSF_TRANSMITTER_ENABLED=true`, a realm whose `ssf_transmitter_enabled` is on is an [OpenID Shared Signals Framework 1.0](https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html) transmitter: receivers create a stream and QAuth pushes Security Event Tokens (RFC 8417) to it (RFC 8935). With the switch off none of these routes exists; for a realm whose own switch is off they answer the `404` of a route that does not exist, before the request is validated. [SSF transmitter](/operate/ssf-transmitter/) covers setting one up. | Endpoint | Purpose | | -------------------------------------- | --------------------------------------------------------------------------- | | `GET /.well-known/ssf-configuration` | Transmitter metadata (SSF 1.0 §7). Unauthenticated. | | `GET /ssf/jwks.json` | The keys that verify the realm's SETs (its `jwks_uri`). Unauthenticated. | | `POST /ssf/stream` | Create the receiver's stream: `201`, or `409` when it has one in the realm. | | `GET /ssf/stream` | Read it with `?stream_id=`, or list the receiver's streams without. | | `PATCH /ssf/stream`, `PUT /ssf/stream` | Update or replace the receiver-supplied members. | | `DELETE /ssf/stream` | Delete it with `?stream_id=`: `204`. | | `GET /ssf/status`, `POST /ssf/status` | Read or set `enabled`, `paused` or `disabled`, with `delivery_status`. | | `POST /ssf/subjects/add` | Add an RFC 9493 subject: `200`; `403` for an unrelated user. | | `POST /ssf/subjects/remove` | Remove one: `204`. | | `POST /ssf/verify` | Ask for a verification event: `204`; `429` within 10 seconds of the last. | The SET keys are published only at `/ssf/jwks.json`, never in the realm JWKS at `/.well-known/jwks.json`, so a resource server that skips the `typ` check cannot take a SET for an access token. Every stream management route takes the receiver's own access token (`client_credentials`) carrying the `ssf:stream` scope, with the realm's issuer in `aud` and no other realm's issuer. The client must still hold `ssf:stream`. A receiver reaches only its own stream; another `stream_id`, or a stream deleted meanwhile, answers `404`. The verification interval is per receiver. A paused stream holds its events without spending attempts and sends them within 30 seconds of being enabled. In `delivery_status`, an endpoint refused or not reached reads `destination_unreachable`. Delivery is push only. `default_subjects` is `NONE`: a stream receives events only about the subjects its receiver added, and its verification events. `events_supported` lists the CAEP, RISC and QAuth-defined events QAuth's security events are sent as; [SSF events](/reference/ssf-events/) gives the mapping. A receiver may add a user only when the user holds an active consent for the receiver's client or a live refresh token it was issued, or a live refresh token whose audience names the receiver as a resource server; the subject ends once that relation has ended and the events about the user have reached the stream (see [SSF transmitter](/operate/ssf-transmitter/#register-a-receiver)). Errors are problem documents (see [Error model](#error-model)): SSF 1.0 gives stream management statuses but defines no error body, so these routes are not OAuth protocol endpoints. `400` is `validation_error` for a body or query the schema refuses and `bad_request` for a request the protocol refuses, with the reason in `detail` (`endpoint_url is refused`, a transmitter-supplied member that does not match, poll delivery); `401` is `unauthorized` for a request with no token, with a bare `WWW-Authenticate: Bearer` challenge, and `invalid_access_token` or `access_token_expired` for a token that was refused, with `error="invalid_token"` in the challenge; `403` is `forbidden` with `error="insufficient_scope", scope="ssf:stream"` in the challenge for a token without the scope, and `forbidden` with a bare challenge for a user the receiver may not add, an unknown user id included; `404` is `not_found`, for a stream the receiver does not have and, as for any path no route serves, for a realm that does not run the transmitter; `409` is `conflict`; and `429` is `too_many_requests` with `Retry-After`. ### Interaction API `GET /interaction/v1-rc.0/openapi.json` returns the OpenAPI 3.1 document of the Interaction API, the API sign-in pages use to reach the engine, with `Cache-Control: public, max-age=3600`. It is the same document the docs site publishes; the [Interaction API](/reference/interaction-api/) page describes its operations, steps and versioning rules. | Endpoint | Method | Purpose | | ----------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | | `/interaction/v1-rc.0/{interactionId}` | GET | Read the current step and its CSRF value. The interaction id alone is enough; no personal data. | | `/interaction/v1-rc.0/{interactionId}/personal` | GET | Read the current step's personal data. Needs the flow cookie; refused from another origin. | | `/interaction/v1-rc.0/{interactionId}/answer` | POST | Answer the current step, as JSON or as a plain HTML form post. A form post gets `303 See Other`. | | `/interaction/v1-rc.0/{interactionId}/cancel` | POST | Cancel the interaction: the client gets `access_denied`. | An answer and a cancel need the realm origin (`Sec-Fetch-Site: same-origin`, or an `Origin` of the realm), the flow cookie and the CSRF value of the step read, checked in that order; the sign-in engine runs only once all three pass. A request from another origin and a wrong CSRF value get one generic `403` `forbidden`, audited as `request.csrf_rejected` with `form: interaction`. A CSRF value answers its step view once: a processed answer, a refused one included, gives the step a new value, while an answer refused before it is processed (a `400`, a `429`, a `503`) leaves it as it was. A body is JSON or `application/x-www-form-urlencoded` (else `415`), at most 16 KiB, or 64 KiB for an answer to `webauthn_create` (else `413`); a form field may not be repeated (`400` `invalid_answer`). A form post gets `303` to the ceremony app's page of the interaction (under the realm's sign-in pages), or to the final redirect; a JSON answer gets the next step or the redirect as JSON. The redirect is taken from the interaction and the realm, never from the request. When a sign-in finishes, its authorization request goes on (#628): it is checked and decided again for the new session, and the client gets its code, or the next interaction starts; a cancel checks the request again before the client gets `error=access_denied`. Every answer is `Cache-Control: no-store`, sends no CORS header, and every error is a problem document without `detail` or `instance`; a throttled sign-in is `429` with `Retry-After`. Rate limits count per address and route: an answer that carries a credential (a password, a one-time or recovery code, a passkey response, a device's user code) counts against the sign-in form's limit (`LOGIN_RATE_LIMIT` per `LOGIN_RATE_WINDOW`), as one sign-in form post does; any other answer and the cancel count against the authorization endpoint's (`AUTHORIZE_RATE_LIMIT` per `AUTHORIZE_RATE_WINDOW`), as do the personal read and the step read. The ceremony app's server reads the step for every browser, so a step read from one of its addresses that the operator lists in `CEREMONY_APP_PEERS` counts per interaction instead, under `CEREMONY_APP_PEER_RATE_LIMIT` reads a minute. With the list empty, as by default, every person signing in through that server shares its one address bucket. --- ## Hosted UI Server-rendered, cookie-authenticated pages back the browser leg of `authorization_code` — see [Hosted UI](/integrate/hosted-ui/) for the full behaviour, including the pending-authorization mechanics behind the login bounce. | Endpoint | Method | Purpose | | --------------------------------------- | --------- | ----------------------------------------------------------------------------------- | | `/ui/login` | GET, POST | Session-cookie login page and form submission | | `/ui/consent` | GET, POST | OAuth consent screen and decision submission | | `/ui/resume/{handle}` | GET | Resume a pending authorization after login (single-use, 10-minute TTL) | | `/ui/verify` | POST | The form of the emailed verification link ([`GET /auth/verify`](#get-authverify)) | | `/ui/upstream/{type}` | GET | A provider button of the login page: back to the request with `qauth_upstream` | | `/ui/upstream/{type}/start` | GET | Start the routed upstream provider, then the waiting page that sends the browser on | | `/ui/upstream-link/{interactionId}` | GET | "I already have an account" or a new one: the `upstream_link` step's form (#670) | | `/ui/upstream-link-refused` | GET | The page of a refused upstream link (#670); no flow sends the browser here (#1081) | | `/ui/recovery-code/{interactionId}` | GET | "Use a recovery code": the `recovery_code` step's form (#649) | | `/ui/recovery-codes/{interactionId}` | GET | "Save your recovery codes": a new set, shown once, and its confirmation (#649) | | `/ui/passkey/{interactionId}` | GET | "Sign in with a passkey": the `webauthn_get` step's ceremony (#651) | | `/ui/passkey-enrolment/{interactionId}` | GET | "Add a passkey": the `webauthn_create` step's ceremony, or "Not now" (#651) | | `/ui/totp/{interactionId}` | GET | "Enter the code from your authenticator app": the `totp` step's form (#653) | | `/ui/totp-setup/{interactionId}` | GET | "Set up an authenticator app": the secret as a QR code and text, and a first code | The account pages below are registered **only** while the hosted pages are on (`INTERACTION_UI_ENABLED`); with the switch off every path 404s. They are experimental: the design-system account console replaces them. See [Hosted UI → The account pages](/integrate/hosted-ui/#the-account-pages) for the checks every form post meets (#844). | Endpoint | Method | Purpose | | ------------------------------------------------------ | --------- | --------------------------------------------------------------------------------------- | | `/ui/account` | GET | The overview of the browser session's account; a browser with no session signs in first | | `/ui/account/password` | GET, POST | Change the password, with the current one, under the realm's password policy | | `/ui/account/reauthenticate` | POST | "Confirm it's you": a fresh sign-in, then back to the page | | `/ui/account/passkeys/add` | POST | Add a passkey: a fresh sign-in, then the `webauthn_create` step | | `/ui/account/passkeys/{passkeyId}/rename` | POST | Rename a passkey | | `/ui/account/passkeys/{passkeyId}/remove` | POST | Remove a passkey | | `/ui/account/totp/setup` | POST | Set up an authenticator app: a fresh sign-in, then the `totp_setup` step | | `/ui/account/totp/remove` | POST | Turn the authenticator app off | | `/ui/account/recovery-codes/regenerate` | POST | A new set of recovery codes, shown once | | `/ui/account/recovery-codes/confirm` | POST | Confirm the new set is saved | | `/ui/account/sessions/{sessionId}/end` | POST | Sign one session out | | `/ui/account/sessions/end-all` | POST | Sign out everywhere | | `/ui/account/linked-accounts/link` | POST | Link an upstream account by signing in to it | | `/ui/account/linked-accounts/{linkedAccountId}/unlink` | POST | Unlink an upstream account | The wallet-login screens below are registered **only** when `WALLET_FEDERATION_ENABLED` is on; with the flag off (the default) every path 404s. See [Wallet sign-in](/integrate/wallet-login/) for the flow they implement. | Endpoint | Method | Purpose | | ---------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/ui/wallet-login` | GET, POST | GET renders the ADR-009 identifier form; POST starts the sign-in. The form field `device` (`this` \| `other`, absent ⇒ `other`) says where the wallet is: `this` renders the deep link, `other` the QR code (#405). | | `/ui/wallet-login/{handle}` | GET | The waiting screen for a pending presentation. | | `/ui/wallet-login/{handle}/status` | GET | Poll the presentation's outcome. Completes a cross-device sign-in once the presentation lands; a same-device one only once its return leg has, and rejects it if the redirect never arrives. | | `/ui/wallet-login/return` | GET | The same-device return leg: `?response_code=` carries the single-use Response Code the wallet received from `POST /oid4vp/response`. Spent first, then bound to the browser that started the flow; every refusal renders one identical page (#405). | | `/ui/wallet-link` | GET, POST | GET renders the confirmation screen; POST starts the link (issue #238) for a wallet on this device. Linking is same-device only: the form field `device` accepts only `this` (absent means the same), `other` answers `400` `validation_error`. | | `/ui/wallet-link/{handle}` | GET | The waiting/outcome screen for a pending link. | --- ## Developer API (`developer` family, v1-rc.0) A developer's OAuth clients, their static API keys, and the developer's own consents (#524). The paths follow the [API conventions](/reference/api-conventions/): JSON members in `camelCase`, except registered OAuth client metadata, which keeps its RFC 7591 name (`client_id`, `client_name`, `redirect_uris`, `grant_types`, `response_types`, `token_endpoint_auth_method`, `client_secret`); times as RFC 3339 strings; lists as `{ "items": [...], "nextCursor": ... }` with `limit` (1 to 200, default 50) and `cursor`; an `ETag` on every read of a client, and `If-Match` on its update; errors as problem documents. **Authentication.** `Authorization: Bearer ` (or `DPoP` while DPoP is on), where the token's only audience is `urn:qauth:aud:developer:v1-rc.0`, it carries no `act` claim, and its subject is an enabled user of the realm. A token for another family, or with no family audience, is refused with `401` `invalid_access_token`, before the request is validated. **Scopes.** A list or a read needs `qauth:developer:read`; a create, update, delete, secret regeneration, API key mint or revocation, and consent revocation needs `qauth:developer:write` (#933). The two are independent. A token without the scope an operation needs is refused with `403` `forbidden` and `WWW-Authenticate: Bearer realm="OAuth", error="insufficient_scope", scope="…"`, before the request is validated. The token [`POST /auth/login`](#post-authlogin) mints for the developer portal carries both. Another client gets them only when an operator lists the developer audience in its `audience` and the scopes in its `scopes` ([API conventions](/reference/api-conventions/#issuing-a-family-audience)). **Ownership.** Every route reads only the caller's own clients. A client that does not exist, that another developer owns, or that belongs to another realm is `404` `not_found`, never `403`. ### `GET /developer/v1-rc.0/clients` List the caller's clients, newest first. **Query**: `limit`, `cursor`. **`200 OK`** ```json { "items": [ { "id": "0b9f7d4e-5c1a-4f7e-9d2b-3a6c8e1f0a42", "client_id": "3f1c…", "client_name": "My App", "description": null, "redirect_uris": ["https://app.example.com/cb"], "scopes": ["openid"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "client_secret_basic", "enabled": true, "suspended": false, "requirePkce": true, "environment": "production", "staticApiKeysAllowed": false, "createdAt": "2026-10-31T09:30:00.000Z", "updatedAt": "2026-10-31T09:30:00.000Z", "lastUsedAt": null } ], "nextCursor": null } ``` `scopes` is a list, as the client stores it. `environment`, `staticApiKeysAllowed` and `suspended` are read-only. Errors: `400` (`validation_error` for a `limit` above 200, `invalid_cursor` for a cursor this list did not issue), `401`, `403`. ### `POST /developer/v1-rc.0/clients` Create a client. The body is a closed object: `client_name` (required), `description`, `redirect_uris`, `scopes`, `grant_types`, `response_types`, `token_endpoint_auth_method`. Left out, they take the defaults of dynamic registration: `authorization_code` and `refresh_token`, the `code` response type, and a public client (`none`, PKCE). **`201 Created`**, with `ETag` and `Location`: the client, and `client_secret` for a confidential client, in this answer only. Errors: `400` (`validation_error`, `invalid_redirect_uri`, `invalid_client_metadata` for a scope the realm does not allow), `401`, `403` (the scope, or the per-developer client limit), `429`. ### `GET /developer/v1-rc.0/clients/{clientId}` One client, with its `ETag`. `{clientId}` is the client's `id`, not its `client_id`. The `ETag` covers every member but `lastUsedAt`, which changes whenever the client is used and no update writes. Errors: `400`, `401`, `403`, `404`. ### `PATCH /developer/v1-rc.0/clients/{clientId}` Change a client with a JSON Merge Patch (RFC 7396), sent as `application/merge-patch+json`, naming the version it changes in `If-Match`. Members: `client_name`, `description`, `redirect_uris`, `scopes`, `grant_types`, `response_types`, `token_endpoint_auth_method`, `enabled`. A member left out keeps its value; a list is replaced whole; `null` gives a member the value a create gives it when it is left out (`description` none, the lists empty, the grant and response types and the auth method their defaults, `enabled` true). `enabled` does not lift an operator's suspension. The merged client must pass the rules a create checks, and a refusal answers as there: `400` `invalid_redirect_uri` (a user-facing grant left without a redirect URI) or `invalid_client_metadata` (grant and response types that do not fit, a scope the realm does not allow). **`200 OK`**: the client and its new `ETag`. Errors: `400` (`validation_error`, also for a member the patch may not name, such as `client_id`; the create rules above), `401`, `403`, `404`, `412` (`precondition_failed`: the client changed since that `ETag`; read it again), `415` (a body that is not a merge patch), `422` (`unprocessable_content`: `client_name` set to `null`), `428` (`precondition_required`: no `If-Match`). ### `DELETE /developer/v1-rc.0/clients/{clientId}` Delete a client. **`204 No Content`**. Errors: `400`, `401`, `403`, `404`. ### `POST /developer/v1-rc.0/clients/{clientId}/regenerate-secret` Issue a new `client_secret` for a confidential client; the old one stops working at once. **`200 OK`**: the client and `client_secret`, in this answer only. Errors: `400` (a public client has no secret), `401`, `403`, `404`, `429`. ### `GET /developer/v1-rc.0/clients/{clientId}/api-keys` A client's static API keys, newest first, masked (`prefix`, `last4`), revoked ones included (`revokedAt` set). `clientId` in each key is the client's `id`. **Query**: `limit`, `cursor`. **`200 OK`**: `{ "items": [...], "nextCursor": ... }`. Errors: `400`, `401`, `403`, `404`. ### `POST /developer/v1-rc.0/clients/{clientId}/api-keys` Mint a key: `{ "name": "ci" }`. Only while the client's effective environment is `development` (ADR-008 §6). **`201 Created`**: the key's fields and the plaintext `key`, in this answer only. Errors: `400`, `401`, `403` (the scope, or not a development client: use `client_credentials`), `404`, `429`. ### `DELETE /developer/v1-rc.0/clients/{clientId}/api-keys/{keyId}` Revoke a key, idempotently: **`200 OK`** with the key, `revokedAt` set. Errors: `400`, `401`, `403`, `404`. ### `GET /developer/v1-rc.0/consents` The caller's own active consents, as a user, by when each was first given, newest first (a new grant to the same client keeps its place, so a page never skips one). It serves the developer portal until the account console takes them over. **`200 OK`** ```json { "items": [ { "id": "0b9f7d4e-5c1a-4f7e-9d2b-3a6c8e1f0a42", "client_id": "https://agent.example.com/client.json", "client_name": "My Agent", "scopes": ["openid"], "grantedAt": "2026-10-31T09:30:00.000Z" } ], "nextCursor": null } ``` Errors: `400`, `401`, `403`. ### `DELETE /developer/v1-rc.0/consents/{consentId}` Revoke one of the caller's consents, with the refresh tokens and the unredeemed authorization codes the caller holds for that client. **`204 No Content`**. Errors: `400`, `401`, `403`, `404` (a consent of another user is the same `404` as one that does not exist). --- ## Account API (`account` family, v1-rc.0) An end user's own account (#524), for the account console's backend. A token whose only audience is `urn:qauth:aud:account:v1-rc.0`, sent as a Bearer or DPoP token; a token for the developer API is refused here with `401`, and the reverse. A read needs `qauth:account:read` and a change `qauth:account:write` (#933), or `403` `forbidden` with the `insufficient_scope` challenge. The conventions are those of the developer API above. ### `GET /account/v1-rc.0/consents` The user's active consents, in the order and representation of `GET /developer/v1-rc.0/consents`. Errors: `400`, `401`, `403`. ### `DELETE /account/v1-rc.0/consents/{consentId}` Revoke one of the user's consents, as `DELETE /developer/v1-rc.0/consents/{consentId}` does. **`204 No Content`**. Errors: `400`, `401`, `403`, `404`. ### `GET /account/v1-rc.0/linked-accounts` The upstream accounts linked to the user (#670), newest first, a page at a time: each with its `id`, its `provider` type, the provider's `providerName` while the realm has it on, and `createdAt`. Never the identity's subject at the provider. Linking one is no operation here: it is an authorization request with [`qauth_link`](/integrate/oauth-flow/#linking-an-upstream-account), which asks the person to sign in again. Errors: `400`, `401`, `403`. ### `DELETE /account/v1-rc.0/linked-accounts/{linkedAccountId}` Unlink one of the user's upstream accounts: the account no longer signs in through that provider, and the owner gets an email notice. Needs a fresh sign-in with the account's strongest factor and refuses an act-chain token, as every change to the ways into the account does. The account's last way to sign in is `409` `last_credential`; one of another user is the same `404` as one that does not exist. **`204 No Content`**. Errors: `400`, `401`, `403`, `404`, `409`. --- ## Consents (`/consents/`) :::caution[Deprecated] Deprecated since #524: every answer carries the `Deprecation` header (RFC 9745), and the operations are `deprecated` in `openapi.json`. Use [`GET /account/v1-rc.0/consents`](#get-accountv1-rc0consents). The path keeps working until the release-candidate cleanup removes it ([Deprecations](/reference/api-conventions/#deprecations)). ::: Lets a signed-in user manage their own OAuth consent grants from the auth-server's **hosted UI**. **Cookie-authed** (`__Host-qauth_session`), so it carries a CSRF token. If you are calling from a separate origin — the developer portal, or your own front end — use [`/api/consents/`](#consents-api-apiconsents) below instead. That cookie is set only by `POST /ui/login` and is `SameSite=Lax`, so it will not be attached to a cross-site `fetch()` whatever `credentials: 'include'` says. ### `GET /consents/` List the signed-in user's active consents. The response also carries a per-session CSRF token that **must** be echoed back as `X-CSRF-Token` on `DELETE /consents/{id}`. **`200 OK`** ```json { "consents": [ { "id": "...", "clientId": "...", "clientName": "My App", "scopes": ["openid"], "grantedAt": 1750000000000 } ], "csrfToken": "..." } ``` Errors: `401` `unauthorized` (no or invalid session), a problem document like every other error here. The credential is a cookie, so the answer carries no `WWW-Authenticate` challenge. ### `DELETE /consents/{id}` Revoke one consent owned by the signed-in user. The refresh tokens and the unredeemed authorization codes the user holds for that client are revoked with it. Access tokens already issued stay valid until they expire. **Headers**: `X-CSRF-Token: ` (required). **`204 No Content`**. Errors: `400` (`invalid_csrf_token` — missing or mismatched `X-CSRF-Token`; the first outcome you'll hit if the header isn't wired up yet), `401` (`unauthorized` — no or invalid session), `404` (`not_found` — consent does not exist or is not owned by the caller). --- ## Consents API (`/api/consents/`) :::caution[Deprecated] Deprecated since #524, with the same `Deprecation` header. Use [`GET /developer/v1-rc.0/consents`](#get-developerv1-rc0consents) (the developer portal does) or the account API. The shapes below stay as they are until the path is removed. ::: The same consent management, **Bearer-authed** — the credential the developer portal and any other API client actually holds. Same auth model as [`/api/clients/`](#client-management-apiclients): `Authorization: Bearer `, scoped strictly to the token subject, with `qauth:developer:read` to list and `qauth:developer:write` to revoke (#933). **No CSRF token.** A Bearer token is not ambient authority a cross-site page can make the browser attach, so there is nothing for one to add. The cookie-authed `/consents/` above needs one precisely because its credential is ambient. Ownership and auditing are shared with `/consents/`, so the two surfaces cannot diverge on who may revoke what. ### `GET /api/consents/` List the authenticated user's active consents. **Headers**: `Authorization: Bearer ` (required). **`200 OK`** ```json { "consents": [ { "id": "...", "clientId": "...", "clientName": "My App", "scopes": ["openid"], "grantedAt": 1750000000000 } ] } ``` No `csrfToken` field — see above. Errors: `401` (missing/invalid bearer). ### `DELETE /api/consents/{id}` Revoke one consent owned by the authenticated user, with the same effect as [`DELETE /consents/{id}`](#delete-consentsid). **`204 No Content`**. Errors: `401` (missing/invalid bearer), `404` (consent does not exist **or** is not owned by the caller — deliberately the same outcome, so the API never reveals that another user's consent id exists). --- ## Client management (`/api/clients/`) :::caution[Deprecated] Deprecated since #524, with the same `Deprecation` header: use the [Developer API](#developer-api-developer-family-v1-rc0), which serves the same clients and API keys in the API conventions' representation. These routes accept only the developer portal's token, whose audience is `urn:qauth:aud:developer:v1-rc.0` since #524. The shapes below stay as they are until the paths are removed. Each route needs the developer scope of the operation it aliases (#933): `qauth:developer:read` for a `GET`, `qauth:developer:write` for any other method, or `403` `forbidden` with the `insufficient_scope` challenge. The portal's token carries both. ::: Developer-portal API for managing a developer's own OAuth clients. **JSON**, **camelCase**, and authenticated with a developer **`Authorization: Bearer`** access token (from [`POST /auth/login`](#post-authlogin)). Results are scoped to the token subject's `developer_id`; the client secret is **never** returned. :::note[Dynamically registered clients and ownership] A client registered **anonymously** through [`POST /oauth/register`](/integrate/oauth-flow/#dynamic-client-registration-rfc-7591) or materialised from a CIMD document has no developer to attribute to, so its `developer_id` is `NULL` and it will **not** appear here for anyone. That is deliberate: inventing an owner would hand an anonymous caller's client to whoever the server guessed. Manage such a client out of band, or register it with attribution instead. **To get a manageable client from dynamic registration, send your developer access token with the registration request** (#374): ```bash curl -s -X POST https://auth.example.com/oauth/register \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -d '{ "client_name": "My App", "redirect_uris": ["https://app.example.com/cb"] }' ``` The client is then owned by that token's subject and behaves like any client created through `POST /api/clients/`, including the per-developer limit on the number of clients (`MAX_CLIENTS_PER_DEVELOPER`, `403` once reached). The token needs `qauth:developer:write`, as `POST /api/clients/` does (#933), or the registration answers `403` `access_denied`. A token that does not verify is rejected rather than silently ignored — otherwise you would get an unowned client while believing you owned it, which is the failure this note exists to prevent. ::: ### `GET /api/clients/` List the authenticated developer's OAuth clients. **Headers**: `Authorization: Bearer ` (required). **`200 OK`** ```json { "clients": [ { "id": "0190f7…", "clientId": "0190f7a0-…-uuid", "name": "My App", "description": null, "redirectUris": ["http://localhost:5173/callback"], "scopes": ["openid", "profile"], "grantTypes": ["authorization_code", "refresh_token"], "responseTypes": ["code"], "tokenEndpointAuthMethod": "none", "enabled": true, "suspended": false, "requirePkce": true, "createdAt": 1750000000000, "updatedAt": 1750000000000, "lastUsedAt": null } ] } ``` A developer with no clients gets `{ "clients": [] }`. Errors: `401` (missing/invalid bearer). `enabled` is the owner's switch. `suspended` is read-only: `true` when the operator has suspended the client, which is then refused wherever a disabled client is (`unauthorized_client` at `/oauth/authorize`, `invalid_client` at the token, introspection and revocation endpoints), whatever `enabled` says. Only the operator lifts a suspension. > **Ownership & 404 semantics.** Every per-client route is scoped to the token > subject's `developer_id`. A client that exists but is owned by another > developer is reported as **`404 Not Found`** (not `403`) so the API never > confirms the existence of clients the caller does not own. ### `POST /api/clients/` Create an OAuth client owned by the authenticated developer. The server generates the `clientId` (UUID) and, for confidential clients, a 32-byte `clientSecret`. **The plaintext `clientSecret` is returned in this response only** — only its argon2id hash is stored, so it is unrecoverable afterwards. **Headers**: `Authorization: Bearer ` (required). **Body** (all besides `name` optional): | Field | Type | Default | Notes | | ------------------------- | -------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | — | Required, 1–255 chars. | | `description` | string | `null` | | | `redirectUris` | string[] | `[]` | At most 20. Each validated (OAuth 2.1 §10.3 — `https`, loopback `http`, an RFC 8252 private-use scheme such as `com.example.app:`, or a scheme without a period listed in `REDIRECT_URI_CUSTOM_SCHEMES`; any other scheme is refused). **Required (≥1) for user-involving grants** (`authorization_code` / `refresh_token`). | | `scopes` | string[] | `[]` | At most 100. Capped to the realm's allowed-scopes policy (same allowlist as dynamic registration); a scope outside it is rejected. | | `grantTypes` | string[] | `["authorization_code","refresh_token"]` | At most 8. `authorization_code` / `refresh_token` / `client_credentials`. | | `responseTypes` | string[] | `["code"]` | At most 4. OAuth 2.1 only supports `code`. | | `tokenEndpointAuthMethod` | string | `"none"` | `none` (public) / `client_secret_post` / `client_secret_basic` / `private_key_jwt`. | **Rate limit**: per-IP, shared budget with `POST /oauth/register` (`REGISTER_CLIENT_RATE_LIMIT` / `REGISTER_CLIENT_RATE_WINDOW`) — create runs an argon2id hash on every call, so the cap is mandatory (`429` on exceed). **`201 Created`** (`Cache-Control: no-store`) ```json { "id": "0190f7…", "clientId": "0190f7a0-…-uuid", "name": "My App", "description": null, "redirectUris": ["https://app.example.com/cb"], "scopes": ["openid"], "grantTypes": ["authorization_code", "refresh_token"], "responseTypes": ["code"], "tokenEndpointAuthMethod": "client_secret_post", "enabled": true, "suspended": false, "requirePkce": true, "createdAt": 1750000000000, "updatedAt": 1750000000000, "lastUsedAt": null, "clientSecret": "a1b2c3…(64 hex chars, shown once)" } ``` Public clients (`tokenEndpointAuthMethod: "none"`) get **no** `clientSecret`. A repeated scope is stored once. A client created here is not operator-provisioned: an authorization error that `/oauth/authorize` would send before the user sees any QAuth page (for example `login_required` for `prompt=none`) is shown on an issuer page with a link to the redirect URI, not redirected automatically (see [Errors](/integrate/oauth-flow/#errors)). A developer may own at most `MAX_CLIENTS_PER_DEVELOPER` clients (default 100), counting the ones registered with attribution through `POST /oauth/register`. Errors: `400` (invalid `redirectUri`, inconsistent grant/response types, missing `redirectUris` for a user-involving grant, or a scope outside the realm policy), `401` (missing/invalid bearer, or a non-user token), `403` (the developer already owns `MAX_CLIENTS_PER_DEVELOPER` clients), `429` (rate limited). ### `GET /api/clients/{id}` Get one of the developer's clients. Safe fields only — never the secret. **`200 OK`** — the same shape as a `GET /api/clients/` list item. Errors: `401`; `404` (not found or not owned). ### `PATCH /api/clients/{id}` Partially update a client. Any subset of: `name`, `description`, `redirectUris`, `scopes`, `grantTypes`, `responseTypes`, `tokenEndpointAuthMethod`, `enabled`, with the same limits as on create. `clientId`, the secret, and `developerId` are **immutable** here (unknown/immutable keys are ignored). The _effective_ configuration (request value or persisted value) is re-validated: grant/response-type consistency, a redirect URI for user-involving grants, and the realm scope cap when `scopes` is changed. `enabled` is the owner's switch and may be set either way. It does not lift an operator's suspension: `suspended` cannot be written here, and a suspended client stays refused after `"enabled": true` until the operator lifts it. **`200 OK`** — the updated client (safe fields, no secret). Errors: `400` (validation / inconsistent config / disallowed scope / missing redirect for a user-involving grant), `401`, `404`. ### `DELETE /api/clients/{id}` Delete a client. After deletion the client can no longer authenticate at the token endpoint and cannot start new authorization flows. Note: already-issued **access tokens are stateless JWTs** and remain valid until they expire; short access-token lifetimes bound this window. **`204 No Content`**. Errors: `401`; `404` (not found or not owned). ### `POST /api/clients/{id}/regenerate-secret` Issue a new `clientSecret`. The previous secret is invalidated immediately; **the new plaintext secret is returned in this response only.** **`200 OK`** (`Cache-Control: no-store`) — the client (safe fields) plus a `clientSecret` string. Errors: `400` (public client — no secret to rotate), `401`, `404`, `429` (rate limited — argon2id, same per-IP budget as create). Clients may also be obtained outside this API: via **CIMD**, the mechanism MCP Authorization 2026-07-28 says to prefer; via [Dynamic Client Registration](/integrate/oauth-flow/#dynamic-client-registration-rfc-7591) (`POST /oauth/register`), which that revision deprecates but QAuth still supports; or via the `seed-oauth-clients` script. --- ## API keys (`/api/clients/{clientId}/api-keys`) Static, long-lived developer API keys scoped to one of the developer's own OAuth clients — an alternative to `client_credentials` for **development-only** use. Same Bearer developer authentication and ownership/404 rules as client management, above. > **Environment-gated (ADR-008 §6).** Minting a key is permitted only while the > client resolves to a **development** environment; a staging/production (or > unset-environment) client is refused with `403`. Use the OAuth > `client_credentials` grant instead for anything beyond local development. ### `POST /api/clients/{clientId}/api-keys` Mint a static API key for the client. **Request** (`application/json`): `{ "name": "..." }` (1–255 chars). **`201 Created`** ```json { "id": "...", "clientId": "...", "name": "local dev", "prefix": "...", "last4": "...", "createdAt": 1750000000000, "lastUsedAt": null, "revokedAt": null, "key": "...(plaintext, shown once)" } ``` Errors: `400`, `401`, `403` (client not in a development environment), `404`. ### `GET /api/clients/{clientId}/api-keys` List the client's API keys — masked fields only (`prefix` + `last4`, never the key or its hash). Includes revoked keys (`revokedAt` set). **`200 OK`**: `{ "apiKeys": [ { "id": "...", "clientId": "...", "name": "...", "prefix": "...", "last4": "...", "createdAt": 0, "lastUsedAt": null, "revokedAt": null } ] }` Errors: `401`, `404`. ### `DELETE /api/clients/{clientId}/api-keys/{keyId}` Revoke one API key. Idempotent soft-delete — the row is retained with `revokedAt` set, and a revoked key never authenticates again. **`200 OK`** — the revoked key (masked fields, `revokedAt` set). Errors: `401`, `404` (client or key not found / not owned). --- ## Wallet federation transport (`/oid4vp/request/{handle}`, `/oid4vp/response`) **Flag-gated, off by default.** `POST /oid4vp/response` is registered only when `WALLET_FEDERATION_ENABLED=true` **and** a `VerifierProfile` is configured for the deployment; both default off, and with either missing the route does not exist (404) or fails closed. See the [Status page](/reference/status/) for where wallet federation stands overall. **Even when enabled, this endpoint is transport only.** It is the OID4VP 1.0 `direct_post` Response Endpoint: it structurally parses and correlates a wallet's Authorization Response (`vp_token` + `state`, or `error` + `state`) against a single-use presentation request. **It performs no signature, credential, or issuer validation, and authenticates no user.** **Request** (form-urlencoded): `state` (required), `vp_token`, `error`, `error_description`. **`200 OK`** — a transport-level acknowledgement: a JSON object, sent `Cache-Control: no-store` (OID4VP 1.0 §8.2). For a **cross-device** request the object is empty, `{}`, and the wallet is not required to do anything further. For a request the user started as **same-device**, and for every account-link request (linking is same-device only), it is `{ "redirect_uri": "https:///ui/wallet-login/return?response_code=<43 base64url chars>" }` — an absolute URI under the issuer carrying a fresh, single-use Response Code, which the wallet MUST redirect the user agent to (HAIP 1.0 §5.1); it is returned on the accepted path and on a wallet-reported error alike. Either way the acknowledgement confirms only that the response was well-formed and correlated. It does **not** assert that any credential was verified or any user authenticated — whoever follows the `redirect_uri` must also hold the browser binder of the flow the code names, and the return route decides that, not this body. Cross-device (QR) flows are unchanged by #405. ### `GET /oid4vp/request/{handle}` **Flag-gated on the same variable, and registered only alongside the endpoint above.** This is the JAR Request Object Endpoint (RFC 9101 §5.2.2, HAIP 1.0 §5.1): under a `VerifierProfile` that mandates signed Authorization Requests, the wallet invocation URI carries a `request_uri` pointing here instead of the request parameters inline, and the wallet fetches the signed request object from it (#377). **`200 OK`** — the compact JWS, served as `application/oauth-authz-req+jwt` with `Cache-Control: no-store`. Its `x5c` header carries the Verifier's leaf certificate and any intermediates, with the trust anchor excluded; `client_id` is the base64url SHA-256 of that leaf. **It consumes nothing and writes nothing.** The request `state` is not redeemed here — a wallet may legitimately retry the fetch — so single use stays enforced where it belongs, at `POST /oid4vp/response`. An unknown, expired or malformed handle all produce the **same** bare `404`, so the endpoint is not an oracle for which requests exist. --- ## See also - [OAuth 2.1 Flow](/integrate/oauth-flow/) — worked flows with `curl`. - [Hosted UI](/integrate/hosted-ui/) — the login, consent, and resume screens. - [Agent Authorization](/integrate/agent-authorization/) — the agent-native layer (`is_agent`, Token Exchange, scope modes, step-up, audit). - [Code Examples](/integrate/code-examples/) — runnable Node/TS and browser clients. - [MCP Quickstart](/integrate/mcp-quickstart/) — protect an MCP server end-to-end. - [`@qauth-labs/resource-guard`](https://github.com/qauth-labs/qauth/blob/main/libs/sdk/resource-guard/README.md) — the resource-server SDK that validates the tokens these endpoints issue. --- # Code Examples URL: https://docs.qauth.dev/integrate/code-examples/ Copy-paste-ready client code for QAuth. All snippets target a local instance at `http://localhost:3000` (your `JWT_ISSUER`); override with an env var for other environments. See the [API Reference](/integrate/api-reference/) for full endpoint contracts and the [OAuth 2.1 Flow](/integrate/oauth-flow/) for the protocol details. > **Never hard-code secrets.** Client secrets and passwords below are read from > environment variables. Public clients (SPA / native / CLI) hold no secret and > authenticate with PKCE instead. --- ## Node.js / TypeScript Requires **Node 20+** (global `fetch`). Run any snippet with `npx tsx example.ts`. ### 1. First-party: register → login → call a protected endpoint Mirrors the [first-party auth endpoints](/integrate/api-reference/#first-party-authentication): create a user, log in for tokens, then call a Bearer-protected endpoint (`GET /api/clients`). Registration answers every address with the same `202`, and a new account can log in only once it is verified (the realm's `accounts.requireVerifiedAccount`, on by default). So run the script once with `QAUTH_REGISTER=1`, open the link in the email and confirm with the password, then run it again without the variable. ```ts // first-party.ts const QAUTH = process.env.QAUTH_URL ?? 'http://localhost:3000'; const email = process.env.QAUTH_EMAIL ?? 'dev@example.com'; const password = process.env.QAUTH_PASSWORD ?? 'corr3ct-h0rse-batt3ry'; // demo only async function post(path: string, body: unknown, token?: string) { const res = await fetch(`${QAUTH}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(body), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(`${path} → ${res.status}: ${JSON.stringify(json)}`); return json as any; } async function main() { // 1. Register, once. The answer is the same 202 whether or not the address // already has an account; the next step arrives by email. if (process.env.QAUTH_REGISTER === '1') { const { message } = await post('/auth/register', { email, password }); console.log(message); console.log('Open the link in the email, confirm, then run again without QAUTH_REGISTER.'); return; } // 2. Log in → tokens. Until the address is verified this is the same 401 as a // wrong password. const { access_token, refresh_token, expires_in } = await post('/auth/login', { email, password, }); console.log(`logged in; access token expires in ${expires_in}s`); // 3. Call a protected endpoint with the bearer token. const res = await fetch(`${QAUTH}/api/clients`, { headers: { Authorization: `Bearer ${access_token}` }, }); const data = await res.json(); console.log(`GET /api/clients → ${res.status}`, data); // { clients: [] } for a new user // 4. (Later) renew the access token without re-prompting: const renewed = await fetch(`${QAUTH}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token }), }).then((r) => r.json()); console.log('refreshed access token:', renewed.access_token?.slice(0, 12), '…'); } main().catch((e) => { console.error(e); process.exit(1); }); ``` ### 2. Machine-to-machine: `client_credentials` → call a resource For service clients (no user). The token is audience-bound (RFC 8707) and useless at any other resource server. Provision the client with the `seed-oauth-clients` script so it has the right `scopes` + `audience` (see the [MCP Quickstart](/integrate/mcp-quickstart/#option-b--verify-the-handshake-with-curl-no-browser)). ```ts // machine.ts const QAUTH = process.env.QAUTH_URL ?? 'http://localhost:3000'; const RESOURCE = process.env.MCP_RESOURCE ?? 'http://localhost:8088'; const clientId = process.env.CLIENT_ID!; // e.g. "memory-mcp-demo" const clientSecret = process.env.CLIENT_SECRET!; // from the seed script output async function getToken(scope: string): Promise { const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); const res = await fetch(`${QAUTH}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${basic}`, // client_secret_basic }, body: new URLSearchParams({ grant_type: 'client_credentials', scope, resource: RESOURCE, // binds the token `aud` to this resource }), }); if (!res.ok) throw new Error(`token → ${res.status}: ${await res.text()}`); const { access_token } = await res.json(); return access_token; } async function main() { const token = await getToken('mcp:read'); const res = await fetch(`${RESOURCE}/mcp/memory`, { headers: { Authorization: `Bearer ${token}` }, }); console.log(`GET ${RESOURCE}/mcp/memory → ${res.status}`, await res.json()); } main().catch((e) => { console.error(e); process.exit(1); }); ``` ```bash CLIENT_ID=memory-mcp-demo CLIENT_SECRET= npx tsx machine.ts ``` --- ## Browser / JavaScript — Authorization Code + PKCE A public SPA client (no secret) using the Web Crypto API for PKCE. Register the client first with `token_endpoint_auth_method: "none"` and your `redirect_uri` (see the [OAuth Flow, step 0](/integrate/oauth-flow/#0-prerequisites--a-client)). The app calls the token endpoint and UserInfo from its own origin, so the operator lists that origin in [`CORS_ORIGIN`](/operate/configuration/). The sign-in itself is a redirect to `/oauth/authorize`, which answers no CORS (OAuth 2.1 §3.1): never `fetch()` it. ```js // auth.js — drop into a SPA; serves the redirect at /callback const QAUTH = 'http://localhost:3000'; const CLIENT_ID = 'YOUR_PUBLIC_CLIENT_ID'; const REDIRECT_URI = window.location.origin + '/callback'; const SCOPE = 'openid profile email'; // No RFC 8707 `resource` here: this app calls QAuth's own UserInfo, which // accepts a token whose `aud` is the app's client_id — the default when no // `resource` is named. A token bound to another resource server is refused // there (401). See /integrate/oauth-flow/#userinfo-oidc. // --- PKCE helpers (RFC 7636) --- function base64url(bytes) { return btoa(String.fromCharCode(...new Uint8Array(bytes))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } function randomVerifier() { return base64url(crypto.getRandomValues(new Uint8Array(48))); // 64 chars } async function challengeFrom(verifier) { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); return base64url(digest); } // --- 1. Begin login: redirect to /oauth/authorize --- async function login() { const verifier = randomVerifier(); const state = base64url(crypto.getRandomValues(new Uint8Array(16))); sessionStorage.setItem('pkce_verifier', verifier); sessionStorage.setItem('oauth_state', state); const params = new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_challenge: await challengeFrom(verifier), code_challenge_method: 'S256', scope: SCOPE, state, }); window.location.assign(`${QAUTH}/oauth/authorize?${params}`); } // --- 2. Handle the redirect back at /callback --- async function handleCallback() { const url = new URL(window.location.href); const code = url.searchParams.get('code'); const returnedState = url.searchParams.get('state'); if (!code) return; // not a callback if (returnedState !== sessionStorage.getItem('oauth_state')) { throw new Error('state mismatch — possible CSRF'); } const verifier = sessionStorage.getItem('pkce_verifier'); const res = await fetch(`${QAUTH}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, // public client: no secret code_verifier: verifier, }), }); const tokens = await res.json(); if (!res.ok) throw new Error(`token → ${res.status}: ${JSON.stringify(tokens)}`); sessionStorage.removeItem('pkce_verifier'); sessionStorage.removeItem('oauth_state'); return tokens; // { access_token, refresh_token, id_token, expires_in, token_type, scope } // id_token is present because SCOPE above includes `openid` — see // https://docs.qauth.dev/integrate/oauth-flow/#id-token-claims-oidc } // --- 3. Read the user's claims at UserInfo with the access token --- async function callApi(accessToken) { const res = await fetch(`${QAUTH}/oauth/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` }, }); // { sub, email?, email_verified? } — email claims are OMITTED when the // user has no verified email attribute. Always handle absence: // const { sub, email } = await callApi(token); // if (email === undefined) { /* no verified email on record */ } return res.json(); } // Wire up: run handleCallback() on /callback, login() on a button click. handleCallback().then((tokens) => { if (tokens) callApi(tokens.access_token).then((u) => console.log('user:', u)); }); ``` > **Security notes.** Always validate `state` on return (CSRF). Keep the > `code_verifier` out of the URL and in `sessionStorage`. For production SPAs, > prefer storing tokens in memory and renewing via the > [`refresh_token` grant](/integrate/oauth-flow/#refresh-token-rotation) over persisting > long-lived tokens. --- ## See also - [API Reference](/integrate/api-reference/) — full endpoint contracts. - [OAuth 2.1 Flow](/integrate/oauth-flow/) — protocol walkthrough with `curl`. - [`memory-mcp` example](https://github.com/qauth-labs/qauth/blob/main/libs/sdk/resource-guard/examples/memory-mcp/server.ts) — a runnable resource server protected by `@qauth-labs/resource-guard`. --- # Agent Authorization URL: https://docs.qauth.dev/integrate/agent-authorization/ > ADR-007 §2 · epic #181 · the agent-native authorization layer. QAuth is an OAuth 2.1 authorization server for MCP servers and AI agents. The [MCP Quickstart](/integrate/mcp-quickstart/) and [OAuth 2.1 Flow](/integrate/oauth-flow/) cover the generic handshake any client uses. **This guide covers the agent-native layer on top of it** — the five pieces that let an autonomous agent act _on behalf of_ a user with bounded, auditable authority: 1. [**Agent client type** (`is_agent`)](#1-agent-client-type-is_agent) — marking a client as an agent, and why the flag is treated as untrusted. 2. [**Token Exchange** (RFC 8693)](#2-token-exchange-rfc-8693--on-behalf-of-delegation) — exchanging a user's token for a delegated one carrying an `act` (actor) claim. 3. [**Scope modes**](#3-scope-modes-readonly--admin--exec) — `agent:readonly` / `agent:admin` / `agent:exec` reserved scopes bounded by an operator-set cap. 4. [**Step-up authentication**](#4-step-up-authentication-before-dangerous-operations) — forcing fresh auth / re-consent before dangerous operations. 5. [**Per-agent audit**](#5-per-agent-action-audit) — what every delegated action records. The unifying principle is **default-deny on untrusted input**: a client can _claim_ to be an agent, but every capability that claim would unlock is gated by an independent, server-side criterion. A client that omits `is_agent` to dodge controls simply is not an agent and gets nothing extra; a client that asserts it still cannot exceed what an operator provisioned for it. --- ## 1. Agent client type (`is_agent`) An **agent** is an OAuth client whose `oauth_clients.is_agent` column is `true`. There are three ways it is set, all of which write the same column: | Path | How | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **Dynamic Client Registration** (RFC 7591) | Send `"is_agent": true` in the `POST /oauth/register` body. Optional boolean, defaults to `false`; echoed back in the response as `"is_agent": true`. | | **Client ID Metadata Documents** (CIMD) | Include `"is_agent": true` in the hosted metadata document the `client_id` URL resolves to. | | **Seed / operator provisioning** | Set `"is_agent": true` on a client entry in the `seed-oauth-clients` manifest. | Register an agent via DCR: ```bash curl -s -X POST http://localhost:3000/oauth/register \ -H 'Content-Type: application/json' \ -d '{ "client_name": "My Agent", "grant_types": ["client_credentials"], "response_types": [], "token_endpoint_auth_method": "client_secret_basic", "is_agent": true }' | jq # → { "client_id": "…", "client_secret": "…", "is_agent": true, … } ``` > ℹ️ **Ask for the delegation grant explicitly.** The registration above gets you > a client, but §2's on-behalf-of delegation additionally requires > `urn:ietf:params:oauth:grant-type:token-exchange` in `grant_types` — `POST > /oauth/token` gates delegation on `client.grantTypes.includes(...)`, so a > client without it is refused with `unauthorized_client`: > > ```jsonc > { > "client_name": "My Agent", > "grant_types": ["client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"], > "response_types": [], > "token_endpoint_auth_method": "client_secret_basic", > "is_agent": true, > } > ``` > > Two constraints to know: > > - **The client must be confidential.** Token exchange authenticates the agent > through the confidential client-auth path, so > `token_endpoint_auth_method: "none"` is refused at registration rather than > at every exchange. On CIMD, where a client is public unless it publishes a > key set for `private_key_jwt`, the grant is dropped from the materialised row > instead — CIMD documents describe a client across every AS it talks to, so an > unusable-here grant is not an error. > - **`response_types` must be `[]`** for a token-endpoint-only registration. > That is not specific to this grant — `client_credentials` alone needs it too, > because the normalizer refuses a `code` response type without the > `authorization_code` grant. > > Holding the grant confers no authority by itself. The exchange still requires a > valid subject token you already hold, preserves-or-narrows its scope and > audience, and clamps any reserved `agent:*` scope to the operator-set > `max_agent_mode` — which self-registration never sets. See §2. > > _(Before #381 this was impossible: the grant was advertised and enforced but > rejected by every provisioning path, so it had to be written straight into the > `oauth_clients.grant_types` JSONB column.)_ ### An agent's owner is a verified account A registration that carries a developer's access token (the developer portal's token, with `qauth:developer:write`) makes the developer the client's owner. For an agent client that makes the developer the owner of an agent, and only a **verified account** may own one (ADR-015 Decision 8). An account is verified when the identity it was created with — its primary identity — is proved: | The account was created with | It is verified when | | ---------------------------- | ------------------------------------------------------------------------------------------------- | | a password | the code mailed to its address was entered | | a passkey | its registration checked the authenticator's user-verification flag | | a wallet credential | always: only QAuth's own verifier writes one | | an upstream provider | the realm still offers the provider, and the provider's "accounts count as verified" switch is on | A credential added later never counts: an account created with an unconfirmed address stays unverified after it adds a passkey. A password account is found and reset by its address, so a key added later proves who holds the account but not that the address is theirs; counting it would let someone who registered another person's address keep the account from its owner. `REQUIRE_VERIFIED_ACCOUNT`, which only decides whether an unconfirmed password account may sign in, plays no part. Otherwise the registration is refused with `403` `{"error": "access_denied"}`, no client is created, and the refusal is recorded as the `user.agent_ownership_refused` event on the account, with the reason. A registration with no token has no owner and is not checked. ### `is_agent` is self-asserted and untrusted `is_agent` is **client-controlled input** — the client sets it in its own DCR body or CIMD document. QAuth therefore **never trusts it alone**. It is read through a fail-closed accessor (`isAgentClient(client)`, which is true only for an explicit `isAgent === true`, never a missing/null value), and on its own it gates nothing dangerous. Every agent capability requires a _second_, **server-side** condition: - **Token Exchange** additionally requires confidential-client authentication _and_ that the subject token was minted for this agent (see §2). - **Scope modes** additionally require an operator-set `max_agent_mode` cap that covers the requested mode (see §3) — and that cap is **not** accepted from the client's own registration request. The safe outcome is the same whether a client lies, omits, or sends a malformed value: no agent privilege. --- ## 2. Token Exchange (RFC 8693) — on-behalf-of delegation > On-behalf-of delegation is an MCP auth **extension** > ([ext-auth](https://github.com/modelcontextprotocol/ext-auth)), not core MCP — > QAuth provides it as a value-add. An agent exchanges a user's access token (`subject_token`) for a **delegated** access token whose `sub` remains the user and whose `act` (actor) claim identifies the agent. Chained delegation nests `act` (RFC 8693 §4.1). ```bash 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: ```jsonc { "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", } ``` ### Request parameters | Parameter | Required | Notes | | ----------------------- | -------- | --------------------------------------------------------------------------------------------- | | `grant_type` | yes | Exactly `urn:ietf:params:oauth:grant-type:token-exchange`. | | `subject_token` | yes | The user's QAuth-issued access token (the party being acted for). | | `subject_token_type` | yes | Must be `urn:ietf:params:oauth:token-type:access_token`. Any other type → `invalid_request`. | | `actor_token` | no | The acting party's token. When present, `actor_token_type` is **required** (RFC 8693 §2.1). | | `actor_token_type` | cond. | Required iff `actor_token` is sent; must be the access-token type, else `invalid_request`. | | `requested_token_type` | no | If present, must be the access-token type; anything else → `invalid_request`. | | `scope` | no | Space-separated; a **subset** of the subject token's scope. Omit to inherit the full set. | | `resource` / `audience` | no | RFC 8707 / RFC 8693 targets; must fall **within** the subject token's `aud`. Omit to inherit. | ### The `act` claim shape The delegated access token carries an RFC 8693 §4.1 `act` claim. The current actor (the authenticated agent's own `client_id`) is the outermost `act`; any pre-existing delegation chain on the subject token nests beneath it: ```jsonc { "sub": "", // the delegated token's subject stays the USER "act": { "sub": "AGENT_CLIENT_ID", // the agent acting now "act": { "sub": "UPSTREAM_AGENT_ID" }, // a prior actor, if re-exchanged }, } ``` The `act.sub` is always the **authenticated agent's** `client_id` — never a value self-declared in an `actor_token`. Access tokens also carry a `token_use: "access"` marker. ### Security model (default-deny) These guarantees hold even against an attacker who has captured a user's token and knows a public agent `client_id`: - **Agent-only.** The client must be classified as an agent (`isAgentClient(client)`, fail-closed) **and** be granted the token-exchange grant type. Otherwise → `unauthorized_client`. - **Confidential clients only.** Token exchange requires confidential client authentication (`client_secret_basic` / `client_secret_post`). A public agent (`token_endpoint_auth_method=none`) presents no secret and is rejected with `invalid_client`. This is a deliberate security floor (RFC 9700): on-behalf-of delegation must not be mintable by a client that proves only knowledge of a `client_id`. - **Subject token must be bound to the agent.** The `subject_token` must verify as a QAuth-issued **access token** — EdDSA signature + `exp`, QAuth's own `iss`, and an access-token use marker (ID tokens and other JWTs are rejected with `invalid_request`) — **and** its `aud` must contain the requesting agent's `client_id`. Combined with the confidential-client requirement, this closes the unauthorized-delegation gap: a captured token alone is not enough. The subject user must also still exist and be enabled. - **Scope and audience are narrowed, never widened.** A requested `scope` must be a subset of the subject token's scope (else `invalid_scope`); `resource` / `audience` must fall within the subject token's `aud` (else `invalid_target`). - **Scope-mode cap still applies.** Any reserved `agent:*` scope that survives narrowing is additionally checked against the agent's server-side `max_agent_mode` (see §3), so a capped agent cannot launder a higher mode through delegation even when the subject token carried it. An over-cap scope **rejects the whole exchange** with `invalid_scope`; it is never silently reduced to the cap. - **Lifetime never exceeds the subject token.** `expires_in` is clamped to `min(configured_lifespan, subject_token_remaining)`, so a delegated token can never outlast — or be re-exchanged to extend — the authority it derives from. - **Bounded delegation depth.** Each re-exchange nests another `act`. The chain is capped at **4 actors**; a deeper request → `invalid_request`. - **No refresh token** is issued — a delegated token is intentionally short-lived; the agent re-exchanges as needed. - **Every exchange is recorded** as an event (success and failure), including the actor and delegation depth (see §5). For copy-paste `curl` and the full error table, see the [Token Exchange section of the OAuth 2.1 Flow guide](/integrate/oauth-flow/#token-exchange--agent-on-behalf-of-delegation-rfc-8693). --- ## 3. Scope modes (ReadOnly / Admin / Exec) QAuth bounds what an agent can do with a small, opinionated capability taxonomy layered over raw OAuth scopes, so operators get a coarse, legible control instead of hand-curating scope strings. ### The taxonomy | Mode | Reserved scope | Meaning | | -------- | ---------------- | ------------------------------------------------ | | ReadOnly | `agent:readonly` | Read-only access | | Admin | `agent:admin` | Administrative; ranks above ReadOnly for the cap | | Exec | `agent:exec` | Action-taking; the most privileged | Cap ordering — **which modes a client is allowed to request** — is **ReadOnly < Admin < Exec**. A client whose maximum mode is `admin` may request `agent:readonly` and/or `agent:admin`, but not `agent:exec`. The cap is a **maximum**, not an exact match. This ordering is a provisioning ceiling only: it says what a client may ask for, never that one granted scope stands in for another. The modes are plain OAuth scopes (`agent:*`), so the **existing** scope machinery enforces them with no new claim system — the per-client allowlist and `validateScopes` at the token endpoint, scope filtering at the authorize / consent screens, and `@qauth-labs/resource-guard`'s resource-server scope checks. The granted mode is therefore already visible to resource servers in the token's `scope` set. ### The three modes are independent scopes, not a hierarchy > **Corrected 2026-08-06.** This guide previously described `agent:admin` as > "⊇ ReadOnly" and the ordering as "ReadOnly ⊂ Admin ⊂ Exec". **That reading is > wrong at enforcement time**, and a client built on it will break. The three reserved scopes are three ordinary, **independent** OAuth scopes. A broader mode does **not** imply a narrower one: - `@qauth-labs/resource-guard` matches scopes **exactly and case-sensitively** per RFC 6749 §3.3, with no hierarchical or prefix semantics (`missingScopes` in `libs/sdk/resource-guard/src/lib/scope.ts`). This is deliberate and is not changing. - A token carrying only `agent:admin` therefore **does not** satisfy a route that requires `agent:readonly` — it gets a `403 insufficient_scope`, exactly as it would for any other unrelated missing scope. - **A client that needs both must request both**, e.g. `scope=agent:readonly agent:admin`. Being capped at `admin` permits that request; it does not perform it for you. `max_agent_mode` is a **maximum requestable ceiling**, not an implication relation between the scopes. ### Enforcement: deny-by-default, untrusted `is_agent` A reserved `agent:*` scope is granted **only** when **both** hold: 1. the client is classified as an agent via the fail-closed `isAgentClient` accessor (`isAgent === true`, never missing/null), **and** 2. the requested mode is within the client's **server-side** `max_agent_mode` cap. `oauth_clients.max_agent_mode` is **operator-set server state** — provisioned via the seed script or admin, and **never** accepted from a client's own registration request (an escalation control must not be self-asserted). It is **absent from the DCR request and response schemas entirely.** It defaults to `NULL`, meaning **no agent mode is permitted**. Consequences (all fail-closed): - A client that **omits** `is_agent` is not an agent → **no** `agent:*` scope. - A client with **no cap** (`NULL`) gets **no** `agent:*` scope — not even ReadOnly. - An **unknown** stored mode parses to `null` and denies. - A capped agent **cannot** obtain a higher mode (`admin`-capped → `agent:exec` rejected with `invalid_scope`). ### Where the cap is enforced The cap is enforced on **every** path that can grant a reserved-mode scope, so the control holds wherever the code is actually issued: | Grant / surface | Where | | ------------------------- | ------------------------------------------------------------------------------------------------- | | `/oauth/authorize` | `findExceedingAgentScopesForClient` → `invalid_scope` redirect (fast path, prior consent covers). | | `/ui/consent` | The same gate on the POST (code issuance) **and** GET (render) — the common first-time path. | | `authorization_code` | `enforceAgentScopeCap` on the code's scopes at redemption: a cap lowered after consent applies. | | `refresh_token` | `validateScopes` on the refreshed scope set (allowlist and cap) before rotation. | | `client_credentials` | `validateScopes(scope, client.scopes, toAgentScopeContext(client))` in the token endpoint. | | Token Exchange (RFC 8693) | `enforceAgentScopeCap` against the narrowed scope set before the delegated token is minted. | Provision an agent with a cap via the `seed-oauth-clients` manifest: ```jsonc { "realm": "master", "clients": [ { "client_id": "my-agent", "name": "My Agent", // Only authorization_code / refresh_token / client_credentials validate // here — the manifest is checked against the `grant_type` pg enum. The // token-exchange URN fails validation; see the note under §1. "grant_types": ["client_credentials"], "scopes": ["agent:readonly", "agent:admin"], "token_endpoint_auth_method": "client_secret_basic", "is_agent": true, // self-asserted classification "max_agent_mode": "admin", // operator-set ceiling — agent:exec stays denied }, ], } ``` ```bash DATABASE_URL="postgresql://qauth:${DB_PASSWORD}@localhost:5432/qauth" \ pnpm nx run infra-db:db:seed-oauth-clients -- --manifest=/path/to/manifest.json ``` --- ## 4. Step-up authentication before dangerous operations `resource-guard` emits the runtime `403 insufficient_scope` + `WWW-Authenticate` challenge when a resource needs a scope the presented token lacks (shipped in T1). The authorization-server half of that loop lives in `/oauth/authorize`: when a client returns asking for an **increased** scope set, QAuth must not silently widen an existing grant. Crossing into a more-privileged scope set is a **step-up** — it requires a **fresh authentication** and/or **explicit re-consent** (MCP 2026-07-28 incremental consent — the "Step-Up Authorization Flow"). ### What counts as "dangerous" The classification is conservative and **server-side only** — never driven by a client signal: - any `write:*` scope (mutating access), and - the higher agent modes **`agent:admin`** and **`agent:exec`** (both are action-taking). `agent:readonly` is **not** dangerous. ### The decision Evaluated from the requested scopes versus what a prior consent already covers, plus the standard OIDC `prompt` / `max_age` request parameters: - **Any newly-requested scope** beyond a prior consent ⇒ **re-consent** required (never auto-widen). - **`prompt=consent`** ⇒ re-consent even with no elevation. - **Any dangerous scope being granted** — whether newly elevated _or_ already covered by a remembered prior consent ⇒ **fresh login** required. A state-changing capability is only granted right after the user proves presence; a stale grant cannot satisfy it, and no client opt-in is involved. - **`prompt=login`** ⇒ fresh login. - **`max_age`** present and the session is older than it ⇒ fresh login (`max_age=0` demands a brand-new authentication; second-granularity flooring makes the post-login round-trip terminate instead of looping). For an operator-flagged first-party client the prior consent is its administrative consent, the requested scopes minus the dangerous ones, so a dangerous scope always asks for re-consent; a `native_backend` client's request counts as `prompt=login` whatever it says ([Hosted UI](/integrate/hosted-ui/#a-first-party-client)). ### The 403 → re-authorize → retry loop ``` 1. Agent calls the resource with a token lacking the needed (e.g. write/exec) scope. → resource-guard returns 403 insufficient_scope + WWW-Authenticate scope hint. 2. Agent re-runs /oauth/authorize requesting the larger scope set. → the agent scope-mode cap is checked first; an over-cap agent:* scope yields an invalid_scope redirect. → step-up evaluation decides fresh login / re-consent is needed. 3. User re-authenticates at /ui/login (and/or re-consents), creating a fresh session. 4. User returns to the original /oauth/authorize URL; the session is now fresh, step-up is satisfied, and the authorization code is issued. ``` ### `prompt=none` (no UI allowed) When the caller forbids UI but step-up would otherwise display a screen, the authorize endpoint returns the matching OIDC error as an `error=` redirect instead of silently elevating (OIDC Core §3.1.2.1): | Condition | Error code | | ---------------------------------- | ---------------------- | | A fresh authentication is required | `login_required` | | Only re-consent is required | `consent_required` | | Generic interaction fallback | `interaction_required` | A fresh-login requirement takes precedence over a pending consent — the user cannot consent before re-authenticating. An agent client never skips the consent screen, even when a stored consent covers the request: the screen names it as an AI agent and says how long it may keep working ([Hosted UI](/integrate/hosted-ui/#an-ai-agent-client)). So `prompt=none` from an agent answers `consent_required` whenever no fresh login is needed (ADR-015 Decision 11). For a client no operator provisioned (one registered through `POST /oauth/register`, a Client ID Metadata Document or `POST /api/clients`), these errors are shown on an issuer page with a link back to the client rather than redirected automatically (RFC 9700 §4.11.2; see [Errors](/integrate/oauth-flow/#errors)). --- ## 5. Per-agent action audit Every agent-attributable action is recorded as an event in the event store (`token.issued`, `token.request_failed`, `step_up.elevated`; see [Events](/reference/events/)). The agent attribution lives in the envelope and the event's `data`: | Member | What it records | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `subject` | The end user the agent acted **on behalf of**. Absent for `client_credentials` (no subject user). | | `actor.id` | The `client_id` **string** of the agent that performed the action. An event references no table, so the attribution survives deletion of the client row. | | `actor.delegationChain` | The RFC 8693 `act` chain flattened to an ordered list of actor `client_id`s — the acting agent first, each following entry a prior actor. Absent when not delegated. | | `data.scopeMode` | The effective agent scope mode (`readonly` \| `admin` \| `exec`) the action ran under — the highest-ranked mode present in the granted scope set. A rollup only: it does **not** imply the lower modes were granted. Absent when the action carries no agent-mode scope. | | `data.delegationDepth` | How many actors the `act` chain of the issued token holds. | What each grant attributes: - **Token Exchange** — the subject is the user, the actor the agent with its delegation chain, `scopeMode` the highest granted agent mode. Failures are recorded too, as `token.request_failed` with the OAuth error and a reason code. - **`client_credentials`** (agent acting as itself) — the actor is the client, with no chain and no subject user; `scopeMode` is set when the client is a verified agent. An ordinary (non-agent) machine client records **no** `scopeMode`. **No tokens or secrets are ever recorded.** Only public client identifiers reach an event — the `subject_token` / `actor_token`, client secrets and any sensitive material never do, and the event contract's payload rules refuse an event that would carry one. The granted scopes (including every `agent:*` scope) are the event's `data.scopes`; `scopeMode` is the rollup over them. Before 0.2.0 these attributes were columns of the `audit_logs` table (`actor_client_id`, `delegation_chain`, `scope_mode`). The table is read-only since then; the upgrade copied its rows into the event store. --- ## See also - [OAuth 2.1 Flow](/integrate/oauth-flow/) — the underlying endpoints with copy-paste `curl`, including the [Token Exchange](/integrate/oauth-flow/#token-exchange--agent-on-behalf-of-delegation-rfc-8693) section. - [API Reference](/integrate/api-reference/) — endpoint contracts, including the `is_agent` DCR field and the token-exchange grant. - [MCP Quickstart](/integrate/mcp-quickstart/) — end-to-end QAuth → MCP handshake. - [ADR-007: MCP-First Positioning](/reference/records/adr/007-mcp-first-positioning/) — why the agent-native layer is QAuth's differentiation. - [`@qauth-labs/resource-guard`](https://github.com/qauth-labs/qauth/blob/main/libs/sdk/resource-guard/README.md) — the resource-server SDK that emits the `403 insufficient_scope` step-up challenge. --- # Wallet sign-in (OID4VP) URL: https://docs.qauth.dev/integrate/wallet-login/ How an end user signs in with a wallet credential, what the server does at each step, and what is deliberately not built yet. Implements [ADR-004](/reference/records/adr/004-wallet-agnostic-federation/) and [ADR-009](/reference/records/adr/009-wallet-account-resolution/) (issue #239). > **Experimental.** Wallet sign-in is off by default, and every route it adds is > tagged `x-qauth-stability: experimental` in the API reference. The verifier > profile it has been validated on, `oid4vp-1.0-base`, is an > [experimental item](/reference/experimental-features/#oid4vp-10-base): it may > change in a minor release without a deprecation window, and the server logs a > warning at boot while it is on. > **Status (updated by #238): the sign-in completes, and a wallet credential can > be linked to an existing account.** Presentation validation (#234), issuer > trust (#236) and subject resolution (#300) are now wired end to end — see > [The authentication seam](#the-authentication-seam) and > [Account linking](#account-linking-238). A deployment additionally has to > configure `OID4VP_ISSUER_JWKS` (whose key each issuer signs with) and > `OID4VP_SUBJECT_BINDING_CLAIMS` (the entitlement check). Since #379 the second > of those is a **boot requirement**, not a runtime one: a deployment with > `WALLET_FEDERATION_ENABLED=true` and no usable subject-resolution > configuration now **refuses to start**, naming the variable to set. It > previously started and then refused every presentation. > > Claims normalization into `user_attributes` (#235) landed too: a validated > credential's claims become `source='wallet'` attribute rows at every wallet > sign-in. > > **Behaviour change (ANT-2026-NCX3VXDP, breaking).** `v0.1.0-rc.1` to > `v0.1.0-rc.3` created an account when a wallet was presented on an identifier > that had none. Wallet sign-in no longer creates accounts > ([ADR-009](/reference/records/adr/009-wallet-account-resolution/) §1, amended > 2026-10-19): that presentation is refused exactly like one on a taken > identifier. Accounts created that way keep signing in. To give someone wallet > sign-in, they sign in to their account and link the wallet > ([Account linking](#account-linking-238)). Wallet-first sign-up arrives with > the hosted sign-up pages > ([ADR-017](/reference/records/adr/017-first-party-login/)). > > The whole flow is covered end to end against real containers and a mock wallet > (#240) — see [End-to-end tests](#end-to-end-tests-and-the-mock-wallet-240). > Interoperability with a REAL wallet remains **unverified**. > > **Since #405 the user says where the wallet is.** A sign-in started with > **Use a wallet on this device** is finished on a browser return leg — the > Response Endpoint hands the wallet a `redirect_uri` carrying a single-use > Response Code (OID4VP 1.0 §8.2 / §14.2, HAIP 1.0 §5.1) and only the browser > that started the flow can spend it; a sign-in started with **Scan with a > wallet on another device** completes by polling exactly as before. See > [The flow](#the-flow) and [ADR-013](/reference/records/adr/013-same-device-return-leg/). > > **Behaviour change (2026-11-07): linking is same-device only.** A wallet is > linked to an account only from a browser on the same device as the wallet. > The linking page has no QR code, and a request for the cross-device mode > (`device=other`) is refused with `400` `validation_error`. A link flow > started cross-device before the upgrade is refused when it is polled. Wallet > sign-in keeps both modes. See [Account linking](#account-linking-238). ## Where it lives The browser-facing screens are server-rendered by `apps/auth-server`, next to the password login they extend: | Path | What it does | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `GET /ui/login` | Password form; offers a **Sign in with your wallet** link when the flow is usable | | `GET /ui/wallet-login` | Collects the account identifier the user asserts | | `POST /ui/wallet-login` | Issues an OID4VP request; `device=this` renders the deep link, `device=other` (or absent) the QR code | | `GET /ui/wallet-login/:handle` | Re-renders a flow in progress; the no-JavaScript refresh path | | `GET /ui/wallet-login/:handle/status` | Polled by the page; completes a cross-device sign-in when the wallet has responded, and a same-device one once its return leg has | | `GET /ui/wallet-login/return` | The same-device **return leg** — the `redirect_uri` the wallet brings the browser back to, carrying the Response Code (#405) | | `GET /ui/wallet-link` | **Signed-in only.** Confirmation screen for linking a wallet credential (#238) | | `POST /ui/wallet-link` | Starts a linking flow for a wallet on this device; renders the deep link, never a QR code; refuses `device=other` | | `GET /ui/wallet-link/:handle` | Re-renders a linking flow; the no-JavaScript refresh path | | `POST /auth/link/wallet` | JSON API: starts a linking flow (session cookie + `X-CSRF-Token`; optional `{ "device": "this" }`, `other` refused) | | `GET /auth/link/wallet/:handle` | JSON API: polls; reports the outcome once the return leg has completed the link | | `POST /oid4vp/response` | The `response_uri` the **wallet** posts to (issue #233) — not a browser surface; answers `{ redirect_uri }` for a same-device flow | | `GET /oid4vp/request/:handle` | The `request_uri` a **wallet** fetches a signed request object from (#377) | `libs/ui` is the React primitive set used by the developer portal; it is not involved here. ## The flow 1. **The user asserts an account.** The identifier field is REQUIRED. ADR-009 Decision §1: `asserted-lookup` is the universal default because no protocol-guaranteed stable wallet subject identifier exists, so **there is no usernameless wallet login**. The user says which account they mean; the presentation proves entitlement to it. The identifier is normalized exactly as the password provider normalizes an email, because it lands in the same `user_credentials.external_sub` column. It is never looked up at this step. Every submission renders the pending page — a QR code or a deep link — whether or not the account exists, so the screen is not an account oracle. The identifier must belong to an account the wallet was linked to. A presentation on an identifier with no account is refused exactly like one on an account the wallet is not bound to, and creates nothing, so the field can neither reveal which accounts exist nor claim one. 2. **The user says where the wallet is.** The identifier form has two submit buttons on the one form — **Use a wallet on this device** (`device=this`) and **Scan with a wallet on another device** (`device=other`). An absent field is cross-device, so anything built before #405 keeps the path it had. The choice is written atomically with the request: `same_device` on the request-state row and `sameDevice` on the flow record. A boolean names no browser, so the wallet-side row still cannot. 3. **The server issues a presentation request.** An OID4VP 1.0 Authorization Request (`response_type=vp_token`, `response_mode=direct_post`) is built under the active `VerifierProfile`, its `state` digest is persisted, and the pending page renders **exactly one** affordance for the wallet invocation URI: the **Open my wallet** deep link — a plain user-gesture `href` — for a same-device flow ("Opening your wallet on this device… When you're done, your wallet will bring you back here."), the QR code for a cross-device one ("Waiting for your wallet…"). Both carry a **Start again** link to a fresh flow with the other choice available. The URI itself is **opaque to the UI**: under `oid4vp-1.0-base` it carries the request inline; under a profile that mandates signed requests it is a `request_uri` reference to a signed JAR the wallet fetches from `GET /oid4vp/request/:handle` (#377). The page renders what the backend produced, and which form it is follows from the Client Identifier Prefix rather than from a setting. 4. **The wallet responds.** It posts the Authorization Response to `POST /oid4vp/response`, which redeems the single-use `state` and publishes a transport signal. That signal says a structurally valid `vp_token` came back — nothing about who presented it. The endpoint answers `200` with a JSON object (OID4VP 1.0 §8.2): `{}` for a cross-device flow, and for a same-device flow `{ "redirect_uri": "/ui/wallet-login/return?response_code=…" }` — a fresh, single-use Response Code minted in the same statement that redeemed the row, on the accepted path **and** on a wallet-reported error (§8.2 permits the member "for Error Responses"; HAIP 1.0 §5.1's MUST has no success qualifier). 5. **The browser is told — two ways.** - **Cross-device:** the page polls the status endpoint every three seconds. On completion it navigates to the original `return_to`; on any failure it shows one fixed sentence. Byte-for-byte the pre-#405 behaviour. - **Same-device:** the wallet follows the `redirect_uri`, in a **new** tab, to `GET /ui/wallet-login/return`. The route spends the code first (one guarded `UPDATE` — unknown, expired, replayed and cross-device codes all fail it identically), then matches the row against the flows this browser's signed binder cookie names, and only then runs the one state machine: the session is minted exactly as by polling and a "You're signed in" page tells the user to go back to the tab where they started — that tab's next poll consumes a done-marker and navigates to `return_to` on its own, so the OAuth client's `state` and PKCE verifier are still in the tab that continues. The poll **never** completes a same-device flow: it answers `pending` after the presentation arrives, and if the redirect has not landed within 180 s (the code's own lifetime) it rejects the presentation outright, with a reason in the log. A landing in a browser that does not hold the flow's binder — a different browser, an in-app browser with its own cookie jar, a replayed code — spends the code, discards the parked presentation, and the initiating tab is told `rejected`; the page it renders is one refusal page for every such case, with no "Try again". ## Scope decision — QR / deep link only OID4VP 1.0 offers two ways to reach a wallet: the cross-device QR / same-device deep link (the two `device` choices above), and the browser-native **Digital Credentials API**, whose `origin:` Client Identifier Prefix is reserved for it. **This ships QR/deep-link only.** The DC API path needs the `origin:` prefix, which QAuth's client-identifier module does not implement. Signed requests are no longer the blocker — #377 wired the ES256/JAR crypto from #298 into the request path — but the missing prefix still is. Shipping half of it would mean an entry point that works in one browser build and fails everywhere else. Because the invocation URI is treated as opaque throughout, adding the DC API later changes what the backend produces, not the structure of these screens. ## Fail-closed gating The wallet entry point appears only when the deployment can actually serve the flow. Every one of these turns it off — with no button rendered, rather than a button that fails: - `WALLET_FEDERATION_ENABLED` is off (default). The routes are not registered at all: the paths 404. - No `VerifierProfile` is selected, or the selected one is not provisioned. There is never a fallback to the more capable profile (#296, LOCKED). - The profile mandates SIGNED requests and the deployment provisioned no verifier signing material. Signing itself is built (#377): a deployment that configures the verifier's certificate chain, and holds the ES256 key the chain names in its key store, signs an `x509_hash`-identified request through the signer and delivers it by `request_uri`. Set `OID4VP_VERIFIER_SIGNING_KEY` for one boot of a process with the signer role and it is imported into the default realm's store (#546); the boot stops when the chain names no stored key. What is refused is the profile mandating signing with nothing to sign with. - The profile signs its requests and the request's realm holds no active `oid4vp_request_signing` key the chain names. Keys are kept per realm, and the import lands in the default realm only, so under `haip-1.0` another realm's sign-in page offers no wallet login (#546). The unsigned `oid4vp-1.0-base` profile reads no key and is offered in every realm. - The profile's response posture contradicts its permitted modes — a table entry requiring encryption while listing only plain `direct_post`, say. An encrypted response itself is never a reason: `direct_post.jwt` is built (#377 Phase C) and needs nothing provisioned, because the ECDH-ES key is minted per request, published in `client_metadata.jwks`, and the private half rides the request-state row until the wallet's JWE arrives — and no longer: the same statement that consumes the row erases the key, so a consumed row can never decrypt its response again. While the row is live the stored key is sealed by the signer with the realm's `data_at_rest` key, bound to the row's `kid` (#546), so it opens only through the signer. `OID4VP_RESPONSE_KEY_SECRET` is read only to open rows a release before 0.2 wrote with it. - The profile does not permit `dc+sd-jwt`, the one credential format QAuth ships an adapter for. - `OID4VP_REQUESTED_VCT` is unset. A DCQL query with no type constraint asks a wallet for any credential it holds, which OID4VP 1.0 §15.6 warns Verifiers against. ## Configuration | Variable | Default | Notes | | ----------------------------------- | -------------- | ----------------------------------------------------------------- | | `WALLET_FEDERATION_ENABLED` | `false` | Registers the wallet provider and these routes | | `OID4VP_VERIFIER_PROFILE` | _(none)_ | `oid4vp-1.0-base`, or `haip-1.0` once fully provisioned (#377) | | `OID4VP_REQUESTED_VCT` | _(none)_ | Comma-separated `vct` values; **no wallet flow without it** | | `OID4VP_WALLET_INVOCATION_ENDPOINT` | `openid4vp://` | The wallet Authorization Endpoint the QR and deep link target | | `OID4VP_VERIFIER_SIGNING_KEY` | _(none)_ | ES256 request key, imported once into the key store (#546) | | `OID4VP_VERIFIER_CERTIFICATE_CHAIN` | _(none)_ | `x5c` chain, leaf first, **anchor excluded** (#377) | | `OID4VP_VERIFIER_TRUST_ANCHORS` | _(none)_ | Anchors QAuth's OWN chain must terminate at (#377) | | `OID4VP_RESPONSE_KEY_SECRET` | _(none)_ | Opens response keys stored before 0.2 only; remove after (#546) | | `OID4VP_TRUSTED_ISSUERS` | _(empty)_ | Per-realm issuer allowlist (#236) — the opposite trust direction | | `OID4VP_ISSUER_JWKS` | _(empty)_ | Issuer public JWKs (#234) — **nothing verifies without it** | | `OID4VP_SUBJECT_RESOLUTION` | _(profile)_ | Account-resolution strategy (#300); defaults to `asserted-lookup` | | `OID4VP_SUBJECT_BINDING_CLAIMS` | _(none)_ | The entitlement check (#300) — **required to BOOT when enabled** | | `OID4VP_ISSUER_ASSURANCE` | _(empty)_ | Per-realm issuer → eIDAS LoA (#237); drives the `acr` claim | | `OID4VP_ATTESTING_ISSUERS` | _(empty)_ | Issuance chains that attest key storage (#308/#379) | | `OID4VP_EMAIL_VERIFYING_ISSUERS` | _(none)_ | Issuers whose email is verified without `email_verified: true` | | `ACR_VALUE_STYLE` | `eidas-uri` | Vocabulary the `acr` value is written in (#237) | ### Subject resolution is required to BOOT when wallet federation is on (#379) `WALLET_FEDERATION_ENABLED=true` with **no** `OID4VP_SUBJECT_*` variables used to start. It is now refused at startup, beside the issuer-allowlist and credential-status gates, with the resolver's own message naming the variable. This is a deliberate breaking change and it takes nothing away. The profile default is `asserted-lookup`, and `asserted-lookup` with no binding claims has nothing to match a presentation against — [ADR-009](/reference/records/adr/009-wallet-account-resolution/) §1 calls that a total authentication bypass, so the server already rejected every presentation. What changes is only where the operator finds out: a failed start instead of a 100% login-failure rate. Set `OID4VP_SUBJECT_BINDING_CLAIMS` (and, for `issuer-scoped-claim`, `OID4VP_SUBJECT_CLAIM` and `OID4VP_SUBJECT_CLAIM_ISSUERS`), or turn `WALLET_FEDERATION_ENABLED` off — which remains a clean boot with no wallet configuration present at all. The **request path is unchanged**. A half-configured deployment that still reaches it logs at `error` and returns the same uniform refusal a forged credential gets; the boot gate replaces the operator's discovery channel and changes nothing an anonymous caller can observe. ### Key-storage assurance (#308/#379) An `OID4VP_ISSUER_ASSURANCE` entry may demand a minimum holder key storage with `requiresKeyStorage: "hardware"`, optionally with a `requiresKeyStorageAttackPotential` floor. Omitting the floor selects the strict reading (`iso_18045_high`), because an unqualified `"hardware"` is asking for eIDAS `high`'s secure cryptographic device. The floor is refused unless `requiresKeyStorage` is `"hardware"`. It decides only whether graded evidence reads as `hardware` or as `software`, and a `"software"` requirement accepts either reading — so under `"software"` every floor in the §D.2 vocabulary admits exactly the same evidence, and an operator who stated one demanded nothing by it. A configuration that reads like a requirement and imposes none is refused rather than ignored, the same way a floor stated with no `requiresKeyStorage` at all is. The evidence comes from `OID4VP_ATTESTING_ISSUERS` — the operator's record of which issuance chains validate a wallet's key attestation per HAIP §4.5.1, and at what grade. Recording an issuer there produces evidence, never a level: the realm still has to name the issuer in its own `OID4VP_ISSUER_ASSURANCE` and accept it in its own `OID4VP_TRUSTED_ISSUERS`. **The recorded grade must clear the selected profile's own floor.** A profile declaring `keyStorageAssurance: required` usually declares a minimum too — `haip-1.0` requires `iso_18045_high` — and a deployment whose `OID4VP_ATTESTING_ISSUERS` records nothing stronger than `iso_18045_basic` would start and then refuse every presentation for attack potential below the minimum. That is refused at startup instead, naming both the profile's floor and the strongest grade recorded. The comparison takes the strongest, not the weakest: a registry pairing a strong ecosystem with a weak one is a working deployment with one ecosystem that will not reach the floor. The recorded grade is a **cap**, not a default result. A recorded issuer may lower it for one credential with an issuer-signed `key_storage` claim, and may never raise it. A `key_storage` claim that cannot be ranked (an unknown value, a bare string, an empty list, `null`) establishes no grade at all, so under `haip-1.0` that credential is refused for attack potential below the minimum. Key attestations conveyed **into a presentation** need wallet-provider trust anchors, which have no configuration surface yet. Until they do, a conveyed attestation is skipped rather than refused and the credential falls through to the transitive path above — a wallet doing the more conformant thing must not be worse off than one conveying nothing. It grants no more than the operator's recorded claim, and the evidence still reports its source as `issuer-attested`. The grade the skipped attestation states, and a `key_storage` claim beside it, are still read (unverified) and can only lower that record: an attestation stating `iso_18045_basic` does not clear the `haip-1.0` floor. One that cannot be read, or states a grade that cannot be ranked, establishes no grade. Note that `oid4vp-1.0-base` declares `keyStorageAssurance: forbidden`, so on the base profile this machinery establishes nothing and changes no behaviour. It is reachable under `haip-1.0`, whose posture is `required` at `iso_18045_high`, and which boots once the verifier identity, an attesting-issuer registry and the status-list pair are all provisioned (#377, #379). See [ADR-010](/reference/records/adr/010-acr-assurance-mapping/) §5. Three variables answer three different questions and must not be conflated: `OID4VP_VERIFIER_PROFILE` is _who are we, to a wallet?_, `OID4VP_TRUSTED_ISSUERS` is _which issuers does a realm accept?_, and `OID4VP_ISSUER_JWKS` is _which key does an issuer sign with?_. Configuring a key grants no trust, and allowlisting an issuer with no configured key can never verify anything — both directions fail closed. The `response_uri` is built on the issuer of the realm the sign-in page was served for, so it is always the same origin that realm publishes as its issuer identifier. So are the `request_uri` and the same-device return URI, for the same reason. No request header decides any of them. The three `OID4VP_VERIFIER_*` variables are a FOURTH question, and the only one pointing outward: _how does QAuth prove who IT is to a wallet?_ Each has a `_PATH` sibling. They are not token-issuance keys — the key configured here is published nowhere, never reaches the JWT plugin, and cannot sign an access or ID token, which `verifier-key-isolation.integration.test.ts` asserts. Unset all three and the deployment runs the unsigned `redirect_uri` path, which is the default; set SOME of them and the boot refuses, because a half-provisioned verifier identity would otherwise fall back silently to a weaker one. `OID4VP_WALLET_INVOCATION_ENDPOINT` accepts any wallet scheme — `openid4vp://`, `haip://`, a vendor's `eudi-wallet://` or its `https://` universal link — but **refuses to boot on a script-capable one** (`javascript:`, `data:`, `vbscript:`, including case- and HTML-entity-encoded spellings). The value is rendered into an `href`, so such a scheme would be a stored XSS payload served to everyone who opens the sign-in screen; the render path re-checks it too. ## Assurance level and the `acr` claim A wallet sign-in can tell a downstream application **how strong** the authentication was. QAuth carries that as the OIDC `acr` (Authentication Context Class Reference) claim of the ID token it issues to the client. The full decision — including the research showing that no registered `acr` value for an eIDAS Level of Assurance exists — is [ADR-010](/reference/records/adr/010-acr-assurance-mapping/). ### What a downstream application sees | Authentication | `acr` in the ID token | | ----------------------------------------------------- | ---------------------------------------- | | Password login | **absent** | | Wallet login, issuer not in `OID4VP_ISSUER_ASSURANCE` | **absent** | | Wallet login, issuer assured `substantial` | `http://eidas.europa.eu/LoA/substantial` | | Wallet login, issuer assured `high` | `http://eidas.europa.eu/LoA/high` | (Values shown for the default `eidas-uri` style; under `loa-name` they are the bare strings `substantial` and `high`.) **The absence of `acr` is the signal for "no higher assurance was established".** It is never emitted with a "low" value, so an RP may treat presence alone as "this session came from an assured credential" and compare the value when it needs a specific level: ```js // Require an eIDAS "high" wallet credential for a sensitive operation. if (idTokenClaims.acr !== 'http://eidas.europa.eu/LoA/high') { // Re-authenticate, or refuse. } ``` `acr` is bound to the authorization code at `/oauth/authorize` time and rendered at `/oauth/token`, so it reflects the authentication that actually backs the code — not the moment the token was requested. Refresh rotation and RFC 8693 token exchange issue no ID token, so neither can launder an `acr` value. ### Where the level comes from Not from the wallet. OID4VP 1.0 §5 fixes the response type to `vp_token`, so there is no wallet-signed assertion for a level to travel in, and HAIP 1.0 §1 states that the profile alone does not reach LoA `high`. The level is a property of the **issuer**: which issuer signed the credential, under which trust anchor, and which credential type it is. An operator states that in `OID4VP_ISSUER_ASSURANCE`, per realm, and QAuth evaluates it against the credential #234 validated — **after** the #236 trust gate has already refused an untrusted issuer. Listing an issuer in `OID4VP_ISSUER_ASSURANCE` does **not** make it trusted. The two variables answer different questions and an issuer must appear in both. ### Reachable as of #240 `resolveWalletPresentation` now returns an authenticated user, so a completed wallet login does produce a level and the resulting ID token carries `acr`. The E2E suite asserts both its presence and its absence, and that the password login still carries no `acr` with wallet federation on or off. This requires `WALLET_FEDERATION_ENABLED=true`; with the flag off the routes are never registered and no wallet login exists to produce a level. ## Security properties - **Login CSRF.** The identifier form carries the same signed double-submit `__Host-` CSRF cookie the password form uses, checked before any presentation request is minted. - **Browser binding.** A flow handle is unguessable, but unguessable is not bound: a handle can be mailed. A separate signed `__Host-qauth_wallet_flow` cookie is minted on the POST that creates the flow and required on every read, so an attacker cannot start a flow, present their own credential and hand the victim a URL that signs the victim in as the attacker. - **Single use, and short.** The presentation request expires in five minutes and its `state` redeems exactly once, in one guarded `UPDATE`. Every terminal outcome deletes the flow record and the wallet signal, so nothing can be polled or replayed afterwards. - **Session fixation.** A fresh session id is minted on every successful authentication, exactly as the password login does. - **Response Code / same-device session binding.** For a flow started with `device=this`, the Response Endpoint's `redirect_uri` carries a fresh 32-byte Response Code whose SHA-256 digest is written in the same statement that redeems the request (only the digest is stored); the return route spends it in one guarded `UPDATE` **before** looking at the browser, then requires the flow's binder cookie as well — two cryptographically random values, one managing state with the wallet and one proving the browser, which is OID4VP 1.0 §14.3.3's second option. The poll never completes such a flow (§14.2: the frontend MUST pass the Response Code), so the poll and the return leg cannot race. A code that lands anywhere is dead everywhere: a return in a different browser or user session spends it and discards the presentation (HAIP 1.0 §5.1), and a redirect never followed rejects it at the 180 s deadline. The code rides a query parameter so the `