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.
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. email already registered) |
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”Create a user account. A verification email is sent (the mock provider logs it).
Request (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
email | string (email) | yes | |
password | string | yes | Strength enforced server-side (zxcvbn score). |
realmId | string (uuid) | no | Defaults to the server’s default realm. |
201 Created
{ "id": "0190f7c2-...", "email": "dev@example.com", "emailVerified": false, "realmId": "0190f7c0-...", "createdAt": 1750000000000, "updatedAt": null}Errors: 400 (validation / weak password), 409 (email already registered),
429 (rate limited). The 409 is deliberately generic — it carries neither the
database constraint name nor anything else that would confirm the address is
already registered.
POST /auth/login
Section titled “POST /auth/login”Authenticate with email/password and receive tokens directly (first-party).
Request (application/json): { "email": "...", "password": "..." }
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).
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”Revoke the caller’s session/token.
Headers: Authorization: Bearer <access_token> (required).
200 OK: { "success": true, "message": "Successfully logged out" }
Errors: 401 (missing/invalid bearer).
GET /auth/verify
Section titled “GET /auth/verify”Confirm an email address from the link in the verification email.
Query: token — 64-char hex string.
200 OK: { "message": "...", "email": "dev@example.com" }
Errors: 400 (malformed token), 404/400 (unknown or expired token).
POST /auth/resend-verification
Section titled “POST /auth/resend-verification”Re-send the verification email. Rate-limited per address (min-interval + per-window caps).
Request (application/json): { "email": "dev@example.com" }
200 OK: { "message": "..." } — returned even for unknown addresses
(no account enumeration). Errors: 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/.
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), 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.
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 and token-exchange "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
(when set on the user, not gated by the profile scope), and email /
email_verified (only under the email scope, same trust-ordered resolution
as the UserInfo response
below). 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 }.
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).
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, cacheable (Cache-Control: public, max-age=3600).
| 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 and renders the QR / deep link. |
/ui/wallet-login/{handle} | GET | The waiting screen for a pending presentation. |
/ui/wallet-login/{handle}/status | GET | Poll the presentation’s outcome, and complete the sign-in once it lands. |
/ui/wallet-link | GET, POST | GET renders the confirmation screen; POST starts the link and renders the QR / deep link (issue #238). |
/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. Cookie-authed
(__Host-qauth_session), not Bearer — there is no first-party access-token path
here, because consent management is inherently a same-origin, user-present
operation.
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.
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).
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, "requirePkce": true, "createdAt": 1750000000000, "updatedAt": 1750000000000, "lastUsedAt": null } ]}A developer with no clients gets { "clients": [] }. Errors: 401 (missing/invalid bearer).
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[] | [] | Each validated (OAuth 2.1 §10.3 — https or loopback). Required (≥1) for user-involving grants (authorization_code / refresh_token). |
scopes | string[] | [] | 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"] | authorization_code / refresh_token / client_credentials. |
responseTypes | string[] | ["code"] | 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, "requirePkce": true, "createdAt": 1750000000000, "updatedAt": 1750000000000, "lastUsedAt": null, "clientSecret": "a1b2c3…(64 hex chars, shown once)"}Public clients (tokenEndpointAuthMethod: "none") get no clientSecret.
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), 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. 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.
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 — an empty transport-level acknowledgement (OID4VP 1.0 §8.3)
confirming the response was well-formed and correlated. It does not assert
that any credential was verified or any user authenticated.
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.