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.
For step-by-step flows with copy-paste curl, see the OAuth 2.1 Flow
guide; for working client code, see Code Examples; for the
login/consent/resume screens an end user sees, see Hosted UI.
Conventions
- Base URL / issuer:
http://localhost:3000(yourJWT_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) useapplication/x-www-form-urlencodedwith 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 (matchingopenapi.json), not the:paramstyle the Fastify route files use internally.
Error model
Section titled “Error model”Errors share a single envelope:
{ "error": "human-readable message", "statusCode": 400, "code": "OPTIONAL_CODE" }Schema-validation failures use:
{ "error": "Validation error", "code": "VALIDATION_ERROR", "statusCode": 400, "details": [{ "path": "/grant_type", "message": "…" }]}details carries one entry per rejected field: path names it, message says
what is wrong with it. Nothing else — 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.
OAuth endpoints additionally return the standard OAuth error codes documented in
OAuth 2.1 Flow → Errors (e.g. invalid_grant,
invalid_client, invalid_scope, invalid_target). On those endpoints error
is the registered RFC 6749 §5.2 token and any human-readable detail is a
separate error_description:
{ "error": "invalid_client", "error_description": "CIMD document is not valid JSON", "code": "INVALID_CLIENT", "statusCode": 401}error_description is omitted where describing the failure would be an
enumeration oracle — client authentication failures answer with the bare token.
| 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 |
System
Section titled “System”Unauthenticated operational endpoints.
Root endpoint. Returns a fixed greeting to verify the server is running.
200 OK: { "message": "Hello API" }
GET /health
Section titled “GET /health”Liveness/readiness probe. Checks database and Redis connectivity.
200 OK (both dependencies reachable):
{ "status": "ok", "timestamp": "2026-07-27T00:00:00.000Z", "services": { "database": "connected", "redis": "connected" }}503 Service Unavailable — same shape with status: "unhealthy" and the
unreachable dependency reported "disconnected".
GET /metrics
Section titled “GET /metrics”Prometheus text-exposition metrics: default Node.js/process metrics plus application counters (login attempts by outcome, tokens issued by type and grant). No authentication — put it behind network-level access control in production.
First-party authentication
Section titled “First-party authentication”Email/password endpoints for end users of your own application. Third-party / MCP clients use the OAuth endpoints instead.
POST /auth/register
Section titled “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).
A mail to an existing account counts toward the same per-address caps as
POST /auth/resend-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 | Strength enforced server-side (zxcvbn score). |
realmId | string (uuid) | no | Defaults to the server’s default realm. |
202 Accepted (Cache-Control: no-store)
{ "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”. With
REQUIRE_VERIFIED_ACCOUNT on (the default), the account can sign in only after
its owner has verified it; until then POST /auth/login
answers it with the same 401 as a wrong password.
Errors: 400 (validation, or an unknown or disabled realmId), 422
(WEAK_PASSWORD, with feedback), 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
Section titled “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
{ "access_token": "eyJ…", "refresh_token": "a1b2…(64 hex)", "expires_in": 900, "token_type": "Bearer"}Errors: 400 (validation), 401 (invalid credentials), 429 (rate limited).
The 401 is the same for an unknown address, a wrong password, a disabled
account and, with REQUIRE_VERIFIED_ACCOUNT on (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
at POST /oauth/token — there is no separate refresh endpoint.
POST /auth/logout
Section titled “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).
Headers: Authorization: Bearer <access_token> (required). Only the access
token from POST /auth/login 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
Section titled “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 link opens the developer portal’s verification page, which asks for the password.
Body: { "token": "<64-char hex string>", "password": "<the account's password>" }
200 OK: { "message": "...", "email": "dev@example.com" }
Errors: 400 (malformed token), 404/400 (unknown or expired token), 401
INVALID_CREDENTIALS (wrong password; the token is not consumed, so the request
can be retried), 429 (rate limited, or the account is locked out: wrong
passwords here count toward the same failed-login lockout as POST /auth/login,
and the 429 carries Retry-After).
POST /auth/resend-verification
Section titled “POST /auth/resend-verification”Re-send the verification email. Rate-limited per address (min-interval +
per-window caps). POST /auth/register 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
Section titled “POST /auth/link/wallet”Start linking a wallet credential to the signed-in account (issue #238,
ADR-004 /
ADR-009 §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" | "other" } —
where the wallet is (#405). this starts a same-device link: render
invocation_uri as a deep link, and the link completes on the browser’s
return leg at GET /ui/wallet-login/return rather than by polling. other,
an empty body, or no body at all is cross-device: render a QR code and poll.
Any other value is a 400 validation error. 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 and means
device: "other".
200 OK: { "handle": "...", "invocation_uri": "openid4vp://...", "expires_at": 1730000000000 }
— render invocation_uri as a QR code or deep link; it is opaque.
Errors: 401 (no session), 400 (invalid_csrf_token, or an invalid
device), 404 (no usable VerifierProfile), 500.
GET /auth/link/wallet/{handle}
Section titled “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. A
link started with device: "this" is never completed by this poll: 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).
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.
OAuth 2.1
Section titled “OAuth 2.1”Full request/response detail and a worked end-to-end walkthrough live in the OAuth 2.1 Flow guide. Contract summary:
| Endpoint | Method | Body type | Purpose |
|---|---|---|---|
/oauth/authorize | 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 | POST | form | authorization_code / refresh_token / client_credentials / token-exchange (RFC 8693) |
/oauth/introspect | POST | form | Token introspection (RFC 7662) — confidential clients only |
/oauth/userinfo | GET, POST | — | OIDC UserInfo (Bearer header, or POST with a form-encoded access_token, RFC 6750 §2.2) |
/oauth/register | POST | JSON | Dynamic Client Registration (RFC 7591, open mode) |
/oauth/revoke | POST | form | Token revocation (RFC 7009) |
Key contract facts:
response_typeiscodeonly;code_challenge_methodisS256only (PKCE required).- Tokens carry
iss,aud(RFC 8707 resource binding),exp,iat, andscope. client_credentialstokens setsub = client_idand issue no refresh token.- Scopes are deny-by-default (client allowlist; DCR clients capped to the
realm’s
DEFAULT_DYNAMIC_REGISTRATION_SCOPES). - The
urn:ietf:params:oauth:grant-type:token-exchangegrant (RFC 8693, ADR-007 §2) lets an agent client delegate on behalf of a user:substays the user and anactclaim names the agent (nested for chained delegation). Agent-only and default-deny; scope/audience are preserved or narrowed, never widened. See the Token Exchange section and the Agent Authorization guide. - Dynamic Client Registration (
POST /oauth/register) accepts the optional QAuth extension fieldis_agent(boolean, defaultfalse) marking the client as an AI agent; it is echoed back in the response. The flag is self-asserted and untrusted — see Agent Authorization.
Token response (POST /oauth/token, 200 OK)
Section titled “Token response (POST /oauth/token, 200 OK)”{ "access_token": "eyJ…", "refresh_token": "a1b2…", // omitted for client_credentials, token-exchange, and a code approved without a stored consent "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, 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
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 for the full
list.
Introspection response (POST /oauth/introspect, 200 OK)
Section titled “Introspection response (POST /oauth/introspect, 200 OK)”{ "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.
UserInfo response (GET|POST /oauth/userinfo, 200 OK)
Section titled “UserInfo response (GET|POST /oauth/userinfo, 200 OK)”{ "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.
POST /oauth/revoke
Section titled “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 outcome is a client
authentication failure (invalid_client).
Discovery
Section titled “Discovery”Unauthenticated and cacheable. The discovery documents carry
Cache-Control: public, max-age=3600; the JWKS carries public, max-age=300.
| 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 — active EdDSA public key(s) (RFC 7517) |
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 enabled, client_id_metadata_document_supported: true
(CIMD).
Hosted UI
Section titled “Hosted UI”Server-rendered, cookie-authenticated pages back the browser leg of
authorization_code — see 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) |
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 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) with the same device form field and the same one-affordance page as the sign-in. |
/ui/wallet-link/{handle} | GET | The waiting/outcome screen for a pending link. |
Consents (/consents/)
Section titled “Consents (/consents/)”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/ 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/
Section titled “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
{ "consents": [ { "id": "...", "clientId": "...", "clientName": "My App", "scopes": ["openid"], "grantedAt": 1750000000000 } ], "csrfToken": "..."}Errors: 401 (no/invalid session) — returns { "consents": [] } with no csrfToken.
DELETE /consents/{id}
Section titled “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: <csrfToken from GET /consents/> (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 (no/invalid session), 404 (consent does not exist or
is not owned by the caller).
Consents API (/api/consents/)
Section titled “Consents API (/api/consents/)”The same consent management, Bearer-authed — the credential the developer
portal and any other API client actually holds. Same auth model as
/api/clients/: Authorization: Bearer <access_token>, scoped strictly to the token subject.
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/
Section titled “GET /api/consents/”List the authenticated user’s active consents.
Headers: Authorization: Bearer <access_token> (required).
200 OK
{ "consents": [ { "id": "...", "clientId": "...", "clientName": "My App", "scopes": ["openid"], "grantedAt": 1750000000000 } ]}No csrfToken field — see above. Errors: 401 (missing/invalid bearer).
DELETE /api/consents/{id}
Section titled “DELETE /api/consents/{id}”Revoke one consent owned by the authenticated user, with the same effect as
DELETE /consents/{id}.
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/)
Section titled “Client management (/api/clients/)”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). Results are scoped to
the token subject’s developer_id; the client secret is never returned.
GET /api/clients/
Section titled “GET /api/clients/”List the authenticated developer’s OAuth clients.
Headers: Authorization: Bearer <access_token> (required).
200 OK
{ "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 as404 Not Found(not403) so the API never confirms the existence of clients the caller does not own.
POST /api/clients/
Section titled “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 <access_token> (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, or an RFC 8252 private-use scheme such as com.example.app:; 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)
{ "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). 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}
Section titled “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}
Section titled “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}
Section titled “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
Section titled “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
(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)
Section titled “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 OAuthclient_credentialsgrant instead for anything beyond local development.
POST /api/clients/{clientId}/api-keys
Section titled “POST /api/clients/{clientId}/api-keys”Mint a static API key for the client.
Request (application/json): { "name": "..." } (1–255 chars).
201 Created
{ "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
Section titled “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}
Section titled “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)
Section titled “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 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 it is
{ "redirect_uri": "https://<issuer>/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}
Section titled “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
Section titled “See also”- OAuth 2.1 Flow — worked flows with
curl. - Hosted UI — the login, consent, and resume screens.
- Agent Authorization — the agent-native layer
(
is_agent, Token Exchange, scope modes, step-up, audit). - Code Examples — runnable Node/TS and browser clients.
- MCP Quickstart — protect an MCP server end-to-end.
@qauth-labs/mcp-guard— the resource-server SDK that validates the tokens these endpoints issue.