# 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 `