Account API
The account API is how a signed-in person manages their own account (ADR-019, Decision 7): the profile, the sign-in methods, the sessions, the security log, the addresses, the connected apps, the notices and deleting the account. The account console calls it from its backend, with the person’s access token (ADR-020 §3).
The contract is one OpenAPI 3.1 document, openapi.json. The typed client in libs/shared/account-client is generated from it, and both are generated from libs/shared/account-contract. The stable-surface check compares the document with the last release (ADR-018 §5). Every operation also follows the API conventions.
Status
Section titled “Status”- This document is the contract, published before the implementation. The auth server serves two of its operations today,
account.consents.listandaccount.consents.delete; the others arrive with the account console’s backend. The two served operations still answer errors with the auth server’s general problem document, which may carrydetailanderrors[].message; the implementation of this contract narrows them to its problem document. It also narrows what they send beyond that:client_nameis the name the client registered, which no display rule has checked; the path accepts an id in capital letters; and the deletion’s answer and the errors carry noCache-Control: no-store. GET /consentsandDELETE /consents/{id}, which the hosted UI calls with the session cookie and anX-CSRF-Tokenheader, are deprecated aliases ofaccount.consents.listandaccount.consents.delete. They are removed when the account console replaces the hosted UI’s consents page.GET /api/consentsandDELETE /api/consents/{id}, deprecated for/developer/v1-rc.0/consents, list and revoke a person’s consents with a developer-audience token. A person’s consents move from the developer portal to the account console (ADR-020 §3), soaccount.consents.*is what the console calls. Retiring the developer family’s consent operations is a change of that family’s own contract.
Tokens
Section titled “Tokens”- The callers are backends, such as the account console’s, that hold a person’s access token whose only audience is
urn:qauth:aud:account:v1-rc.0, sent as a Bearer token or, while the server’s DPoP support is on, a DPoP-bound one. No cookie authenticates a call, and the server sends no CORS headers. Each operation names the audience inx-qauth-audience. - The token’s subject is an enabled user of the request’s realm, and every operation acts on that user’s own account only. A resource of another account answers
not_found, as one that does not exist. - Each operation names the permission it will need in
x-qauth-permissionand its class inx-qauth-class. No scope grants them yet: until the account family defines its scopes, the audience alone admits a token, and a token that lacks a scope an operation requires will get 403forbidden.
Fresh authentication
Section titled “Fresh authentication”- An operation whose
x-qauth-fresh-authenticationisstrongest-factorneeds a sign-in at most 300 seconds old (the token’sauth_time) whoseamrholds the account’s strongest bound factor:popwhen a passkey is bound, elseotpwhen an authenticator app is, elsepwd. strongest-factor-or-recovery-codealso accepts a sign-in of the same age that used a recovery code in place of a lost second factor. Only the two halves of a rebind after a lost factor take it (ADR-017 Decision 8): removing a passkey or the authenticator app, and binding a new authenticator app.- Otherwise the operation answers 401
insufficient_user_authentication(RFC 9470 §3) with the challengeWWW-Authenticate: Bearer error="insufficient_user_authentication", max_age="300"when the sign-in is too old, ormax_age="0"when it is recent but lacks the strongest factor. The problem document carries the samemax_age. The caller sends the person to the authorization endpoint with thatmax_age, and repeats the call with the new token. That sign-in ends with the account’s strongest bound factor, since a sign-in completes only once the transaction has proved it (ADR-017, the downgrade rule).
For example, a password change with a sign-in of ten minutes ago:
HTTP/1.1 401 UnauthorizedCache-Control: no-storeContent-Type: application/problem+jsonWWW-Authenticate: Bearer error="insufficient_user_authentication", max_age="300"
{"type":"https://docs.qauth.dev/reference/problem-types/#insufficient-user-authentication","title":"Insufficient user authentication","status":401,"code":"insufficient_user_authentication","trace_id":"0b9e2c4f-6a1d-4c3e-9f2a-7d5b8e1c3a60","max_age":300}Act-chain tokens
Section titled “Act-chain tokens”- An act-chain token (RFC 8693
act: an admin or an agent acting for the person) never counts as the person’s fresh authentication. - An operation whose
x-qauth-act-chainisrefusedanswers an act-chain token with 403delegated_token_refused, before any fresh-authentication check, so a delegated caller never gets a challenge it cannot meet. Every operation that changes a credential, an identifier, the contact address or a linked account refuses one, and so do deleting the account and reporting a sign-in as not the person’s.
- Typed data from the auth server’s own records, never HTML. Every string a response carries is an enum, a constant or a bounded value with a pattern. A display value has no markup bracket, no control, format, private-use or unassigned character, no line separator, and no space at either end, and
x-qauth-displaysays where it comes from. - A request takes no unbounded text: a value the person types, such as a profile value or a passkey’s name, is a bounded display value too. A password is the one exception: it takes any character, appears only in a request (
writeOnly) and is never shown. - Every error is an RFC 9457 problem document (
application/problem+json) with a stablecodeand atrace_id, and no prose: nodetail, noinstance, and field errors as reason codes. Each error response lists the codes it may carry inx-qauth-problem-codes, a closed list per operation. - A list pages by cursor:
limit(1 to 200, default 50) andcursor, the opaquenextCursorof the previous page, which isnullon the last page. - An update is a JSON Merge Patch (RFC 7396,
application/merge-patch+json) sent withIf-Match, theETagof the resource’s last read. A stale value answers 412precondition_failed, a missing one 428precondition_required. - Every answer carries
Cache-Control: no-store. - The security log keeps an entry for 90 days by default, a realm setting. Its
kindis a closed list, built from QAuth’s events through an allow list.
Operations
Section titled “Operations”Every operation sits under /account/v1-rc.0 and takes a token whose only audience is urn:qauth:aud:account:v1-rc.0. The document gives each one its parameters, its body, one example request and response, and its error responses, each with the codes it may carry. “Its own codes” are the ones beyond what every operation of its shape answers: bad_request, the token guard’s 401s, forbidden, misdirected_request, too_many_requests, internal_error and service_unavailable; validation_error with parameters or a body; invalid_cursor on a list; content_too_large and unsupported_media_type with a body; the precondition codes and unprocessable_content on an update; insufficient_user_authentication with fresh authentication; and delegated_token_refused where an act-chain token is refused.
Profile
Section titled “Profile”The person’s profile fields and groups.
| Operation | Request | Fresh authentication | Act-chain token | Success | Its own codes |
|---|---|---|---|---|---|
Read the profile (account.profile.get) | GET /profile | — | Accepted | 200 | — |
Update the profile (account.profile.update) | PATCH /profile | — | Accepted | 200 | — |
Sign-in methods
Section titled “Sign-in methods”Password, passkeys, the authenticator app (TOTP) and recovery codes.
| Operation | Request | Fresh authentication | Act-chain token | Success | Its own codes |
|---|---|---|---|---|---|
Read the sign-in methods (account.signInMethods.get) | GET /sign-in-methods | — | Accepted | 200 | — |
Change the password (account.password.change) | POST /password/change | Strongest factor | Refused | 204 | invalid_current_password, not_found, weak_password |
List the passkeys (account.passkeys.list) | GET /passkeys | — | Accepted | 200 | — |
Read a passkey (account.passkeys.get) | GET /passkeys/{passkeyId} | — | Accepted | 200 | not_found |
Rename a passkey (account.passkeys.update) | PATCH /passkeys/{passkeyId} | — | Refused | 200 | not_found |
Remove a passkey (account.passkeys.delete) | DELETE /passkeys/{passkeyId} | Strongest factor or recovery code | Refused | 204 | not_found, last_credential, second_factor_required |
Start an authenticator-app enrolment (account.totp.enrollment.create) | POST /totp/enrollment | Strongest factor or recovery code | Refused | 201 | conflict |
Confirm an authenticator-app enrolment (account.totp.enrollment.confirm) | POST /totp/enrollment/confirm | Strongest factor or recovery code | Refused | 200 | invalid_code, not_found |
Remove the authenticator app (account.totp.delete) | DELETE /totp | Strongest factor or recovery code | Refused | 204 | not_found, second_factor_required |
Read the recovery codes’ status (account.recoveryCodes.get) | GET /recovery-codes | — | Accepted | 200 | — |
Regenerate the recovery codes (account.recoveryCodes.regenerate) | POST /recovery-codes/regenerate | Strongest factor | Refused | 200 | conflict |
Sessions
Section titled “Sessions”Where the person is signed in, and “sign out everywhere”.
| Operation | Request | Fresh authentication | Act-chain token | Success | Its own codes |
|---|---|---|---|---|---|
List the sessions (account.sessions.list) | GET /sessions | — | Accepted | 200 | — |
End a session (account.sessions.delete) | DELETE /sessions/{sessionId} | — | Accepted | 204 | not_found |
Sign out everywhere (account.sessions.endAll) | POST /sessions/end-all | — | Accepted | 204 | — |
Security log
Section titled “Security log”The last 90 days of sign-ins, new devices and security changes, and “this wasn’t me”.
| Operation | Request | Fresh authentication | Act-chain token | Success | Its own codes |
|---|---|---|---|---|---|
List the security log (account.securityLog.list) | GET /security-log | — | Accepted | 200 | — |
Report “this wasn’t me” (account.securityLog.report) | POST /security-log/{entryId}/report | — | Refused | 204 | not_found, conflict |
Addresses
Section titled “Addresses”The address the account signs in with, and its contact address.
| Operation | Request | Fresh authentication | Act-chain token | Success | Its own codes |
|---|---|---|---|---|---|
Read the addresses (account.addresses.get) | GET /addresses | — | Accepted | 200 | — |
Start an address change (account.addressChange.create) | POST /address-change | Strongest factor | Refused | 202 | disabled_by_realm |
Confirm an address change (account.addressChange.confirm) | POST /address-change/confirm | Strongest factor | Refused | 200 | invalid_code, not_found, disabled_by_realm |
Connected apps
Section titled “Connected apps”The apps the person consented to, and their revocation.
| Operation | Request | Fresh authentication | Act-chain token | Success | Its own codes |
|---|---|---|---|---|---|
List the connected apps (account.consents.list) | GET /consents | — | Accepted | 200 | — |
Disconnect an app (account.consents.delete) | DELETE /consents/{consentId} | — | Accepted | 204 | not_found |
Account
Section titled “Account”Deleting the account.
| Operation | Request | Fresh authentication | Act-chain token | Success | Its own codes |
|---|---|---|---|---|---|
Preview the deletion (account.deletion.get) | GET /deletion | — | Accepted | 200 | — |
Delete the account (account.deletion.create) | POST /deletion | Strongest factor | Refused | 204 | disabled_by_realm |
Notifications
Section titled “Notifications”The security notices the account receives.
| Operation | Request | Fresh authentication | Act-chain token | Success | Its own codes |
|---|---|---|---|---|---|
Read the notification preferences (account.notificationPreferences.get) | GET /notification-preferences | — | Accepted | 200 | — |
Security log
Section titled “Security log”An entry’s kind is one of a closed list. Each kind is built from QAuth’s events (Events) through an allow list; the security log is read from the event store, never kept on its own. A source with an issue is an event type that the feature of that issue registers when it ships, in the same change that marks it registered in the contract. A test keeps the list matched to the event registry both ways: every user event is either a source here or left out with a reason.
| Kind | What it tells the person | Built from | “This wasn’t me” |
|---|---|---|---|
signed_in | The person signed in. | user.signed_in | Yes |
new_device | The person signed in from a device QAuth had not seen for the account (the device recognition of #689). | user.new_device_seen (with #689) | Yes |
second_factor_failed | A sign-in gave the right password and then a wrong second factor. Only these failed sign-ins are shown: a log of every failed sign-in would tell an attacker which guesses reached the account. | user.sign_in_failed, only a refused second-factor step after an accepted password, whose method is the second factor’s (TOTP from F2c); never a refused identifier or password | No |
session_ended | A session ended: a sign-out, one session ended from the console, or “sign out everywhere”. | user.signed_out | No |
password_changed | The account’s password was changed. | user.password_reset, user.password_changed (with #685) | No |
credential_added | A passkey or an authenticator app (TOTP) was added to the account. | user.credential_added (with #686) | No |
credential_removed | A passkey or an authenticator app (TOTP) was removed from the account. | user.credential_removed (with #686) | No |
credential_disabled | The password or the authenticator app was disabled after too many wrong attempts (ADR-017 Decision 7). | user.credential_disabled (with #693) | No |
recovery_code_used | A recovery code was used in place of a second factor. | user.recovery_code_used | No |
recovery_codes_regenerated | A new set of recovery codes replaced the old one. | user.recovery_codes_regenerated (with #687) | No |
recovery_codes_failed | Recovery codes were tried and refused 100 times (ADR-017 Decision 7). | user.recovery_codes_failed | No |
send_ceiling_reached | Codes were requested for the account’s address past the per-address send ceiling, and a notice with a reset pass went to it (ADR-017 Decision 7). | user.send_ceiling_reached (with #637) | No |
identifier_changed | The address the account signs in with, or its contact address, changed. | user.identifier_changed (with #690) | No |
account_linked | A wallet or an upstream account was linked to the account. | wallet.linked, user.upstream_linked (with #670) | No |
account_unlinked | A wallet or an upstream account was unlinked from the account. | user.upstream_unlinked (with #670) | No |
consent_granted | The person let an app access the account. | consent.granted | No |
consent_revoked | An app’s access to the account was withdrawn. | consent.revoked | No |
A failed sign-in appears only as second_factor_failed, after a correct password: a log of every failed sign-in would tell an attacker which guesses reached the account. No entry carries the raw IP address; the location is approximate.
Notices
Section titled “Notices”The security notices the account receives. A minor release may add one, such as the new-agent-grant notice of ADR-020 §3. Notices go to the contact address; an account without one sees them only in its security log. The mandatory ones always go out; a realm may switch off the others.
| Notice | When | Mandatory |
|---|---|---|
new_device | A sign-in from a device QAuth had not seen for the account. | No |
password_changed | The password was changed. | Yes |
credential_disabled | The password or the authenticator app was disabled after too many wrong attempts. It carries a reset pass. | Yes |
credential_added | A passkey or an authenticator app was added. | Yes |
credential_removed | A passkey or an authenticator app was removed. | Yes |
recovery_code_used | A recovery code was used. | Yes |
recovery_codes_regenerated | The recovery codes were regenerated. | Yes |
recovery_codes_failed | Recovery codes were tried and refused 100 times. | No |
send_ceiling_reached | Codes were requested past the per-address send ceiling. It carries a reset pass. | Yes |
second_factor_failed | A wrong second factor followed a correct password. | Yes |
identifier_changed | The sign-in address or the contact address changed; sent to the old address. | Yes |
account_deleted | The account was deleted. | Yes |
Errors
Section titled “Errors”An error is an RFC 9457 problem details document (application/problem+json) with a stable code and a trace_id, as on the other API families (API conventions). Its type and title are the code’s entry on Problem types. It carries no detail and no instance: a console shows its own text for the code, with the trace id, and treats a code it does not know by its HTTP status. A field error names a reason, one of required, unknown, read_only, too_short, too_long, invalid_format, not_allowed; a refused new password, one or more of too_short, too_long, blocklisted, too_weak, breached.
| Code | Status | Meaning |
|---|---|---|
bad_request | 400 | The request is malformed and no more specific code applies. |
validation_error | 400 | The request does not match the schema of the operation. errors names each field and why. |
invalid_cursor | 400 | The cursor does not parse, or it belongs to another list. |
invalid_current_password | 400 | The current password a password change names is wrong. It counts on the account’s failure ladder, as a failed sign-in does. |
invalid_code | 400 | A one-time code in the request is wrong, expired or used: the first TOTP code of an enrolment, or a code mailed to an address. It counts on the account’s failure ladder for that code. |
unauthorized | 401 | The request carries no access token. WWW-Authenticate names the scheme and no error. |
access_token_expired | 401 | The access token has expired. |
invalid_access_token | 401 | The access token is not valid, is not valid for this operation, or names no active user. |
invalid_dpop_proof | 401 | A call under the DPoP scheme came with no proof, or with one that is not valid for this request (RFC 9449 §7.1). |
insufficient_user_authentication | 401 | The operation needs fresh authentication and the sign-in behind the token is too old or lacks the account’s strongest bound factor (RFC 9470 §3). max_age and the WWW-Authenticate challenge say how fresh the next sign-in must be. |
forbidden | 403 | The token lacks a scope the operation needs. |
delegated_token_refused | 403 | The token is an act-chain token (RFC 8693 act), and the operation changes a credential, an identifier, the contact address or a linked account, or deletes the account: only the person may do that (ADR-019 Decision 7). |
disabled_by_realm | 403 | The realm’s policy does not let a person do this themselves, such as deleting the account or changing the sign-in address. An admin can. |
not_found | 404 | The resource does not exist, or belongs to another account: the answer is the same. |
conflict | 409 | The request conflicts with the current state of the account. |
last_credential | 409 | The change would leave the account with no way to sign in, such as deleting the only passkey of a passkey-only account. |
second_factor_required | 409 | The change would remove the last second factor while the realm’s policy requires one for this account. |
precondition_failed | 412 | The If-Match value does not match the current ETag of the resource. |
content_too_large | 413 | The request body is larger than the server accepts. |
unsupported_media_type | 415 | The media type of the request body is not one the operation accepts. |
misdirected_request | 421 | The server does not answer for the host the request names. |
unprocessable_content | 422 | The request is well formed, but its result fails a rule of the resource, such as a profile field the realm’s profile schema does not accept. errors names each field and why. |
weak_password | 422 | The new password does not meet the realm’s password policy. reasons lists why. |
precondition_required | 428 | The update needs an If-Match header. |
too_many_requests | 429 | Too many requests. Retry-After says when to try again. |
internal_error | 500 | The server failed. trace_id finds the failure in the server log. |
service_unavailable | 503 | The server cannot answer now, for example while a store it needs is down. It fails closed. |
The account API adds invalid_current_password, invalid_code, insufficient_user_authentication, delegated_token_refused, disabled_by_realm, last_credential, second_factor_required to the problem registry.
Versioning
Section titled “Versioning”- The path and the audience carry the major version. Until QAuth 1.0.0 it is a release candidate,
/account/v1-rc.<M>andurn:qauth:aud:account:v1-rc.<M>(today/account/v1-rc.0), and nothing about it is promised: a breaking change raises<M>for both. The release that ships 1.0.0 serves/account/v1. - Within a major version, changes are additive only: new operations, new optional request members, new response members, and new values of an extensible set (problem codes, field error reasons, password rejection reasons, profile field types, security notices).
- A client ignores response members it does not know, and treats a problem code it does not know by its HTTP status. A request with a member the operation does not declare is refused.
- The published schemas close response objects (
additionalProperties: false) to describe this version exactly. A later version may add members, so a client must not validate responses strictly against an older schema, and ignores the members it does not know. - Removing or renaming a member, changing a type, tightening validation, changing a status code, removing an operation, changing what an operation asks of the token (
x-qauth-fresh-authentication,x-qauth-act-chain) and adding to a closed set, such as the security log’s kinds, need a new major version. - A successor major version is served beside the previous one for at least 12 months, and the previous one stays until the next major release of QAuth.