Skip to content

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.

  • This document is the contract, published before the implementation. The auth server serves two of its operations today, account.consents.list and account.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 carry detail and errors[].message; the implementation of this contract narrows them to its problem document. It also narrows what they send beyond that: client_name is 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 no Cache-Control: no-store.
  • GET /consents and DELETE /consents/{id}, which the hosted UI calls with the session cookie and an X-CSRF-Token header, are deprecated aliases of account.consents.list and account.consents.delete. They are removed when the account console replaces the hosted UI’s consents page.
  • GET /api/consents and DELETE /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), so account.consents.* is what the console calls. Retiring the developer family’s consent operations is a change of that family’s own contract.
  • 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 in x-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-permission and its class in x-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 403 forbidden.
  • An operation whose x-qauth-fresh-authentication is strongest-factor needs a sign-in at most 300 seconds old (the token’s auth_time) whose amr holds the account’s strongest bound factor: pop when a passkey is bound, else otp when an authenticator app is, else pwd.
  • strongest-factor-or-recovery-code also 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 challenge WWW-Authenticate: Bearer error="insufficient_user_authentication", max_age="300" when the sign-in is too old, or max_age="0" when it is recent but lacks the strongest factor. The problem document carries the same max_age. The caller sends the person to the authorization endpoint with that max_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 Unauthorized
Cache-Control: no-store
Content-Type: application/problem+json
WWW-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}
  • 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-chain is refused answers an act-chain token with 403 delegated_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-display says 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 stable code and a trace_id, and no prose: no detail, no instance, and field errors as reason codes. Each error response lists the codes it may carry in x-qauth-problem-codes, a closed list per operation.
  • A list pages by cursor: limit (1 to 200, default 50) and cursor, the opaque nextCursor of the previous page, which is null on the last page.
  • An update is a JSON Merge Patch (RFC 7396, application/merge-patch+json) sent with If-Match, the ETag of the resource’s last read. A stale value answers 412 precondition_failed, a missing one 428 precondition_required.
  • Every answer carries Cache-Control: no-store.
  • The security log keeps an entry for 90 days by default, a realm setting. Its kind is a closed list, built from QAuth’s events through an allow list.

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.

The person’s profile fields and groups.

OperationRequestFresh authenticationAct-chain tokenSuccessIts own codes
Read the profile (account.profile.get)GET /profile—Accepted200—
Update the profile (account.profile.update)PATCH /profile—Accepted200—

Password, passkeys, the authenticator app (TOTP) and recovery codes.

OperationRequestFresh authenticationAct-chain tokenSuccessIts own codes
Read the sign-in methods (account.signInMethods.get)GET /sign-in-methods—Accepted200—
Change the password (account.password.change)POST /password/changeStrongest factorRefused204invalid_current_password, not_found, weak_password
List the passkeys (account.passkeys.list)GET /passkeys—Accepted200—
Read a passkey (account.passkeys.get)GET /passkeys/{passkeyId}—Accepted200not_found
Rename a passkey (account.passkeys.update)PATCH /passkeys/{passkeyId}—Refused200not_found
Remove a passkey (account.passkeys.delete)DELETE /passkeys/{passkeyId}Strongest factor or recovery codeRefused204not_found, last_credential, second_factor_required
Start an authenticator-app enrolment (account.totp.enrollment.create)POST /totp/enrollmentStrongest factor or recovery codeRefused201conflict
Confirm an authenticator-app enrolment (account.totp.enrollment.confirm)POST /totp/enrollment/confirmStrongest factor or recovery codeRefused200invalid_code, not_found
Remove the authenticator app (account.totp.delete)DELETE /totpStrongest factor or recovery codeRefused204not_found, second_factor_required
Read the recovery codes’ status (account.recoveryCodes.get)GET /recovery-codes—Accepted200—
Regenerate the recovery codes (account.recoveryCodes.regenerate)POST /recovery-codes/regenerateStrongest factorRefused200conflict

Where the person is signed in, and “sign out everywhere”.

OperationRequestFresh authenticationAct-chain tokenSuccessIts own codes
List the sessions (account.sessions.list)GET /sessions—Accepted200—
End a session (account.sessions.delete)DELETE /sessions/{sessionId}—Accepted204not_found
Sign out everywhere (account.sessions.endAll)POST /sessions/end-all—Accepted204—

The last 90 days of sign-ins, new devices and security changes, and “this wasn’t me”.

OperationRequestFresh authenticationAct-chain tokenSuccessIts own codes
List the security log (account.securityLog.list)GET /security-log—Accepted200—
Report “this wasn’t me” (account.securityLog.report)POST /security-log/{entryId}/report—Refused204not_found, conflict

The address the account signs in with, and its contact address.

OperationRequestFresh authenticationAct-chain tokenSuccessIts own codes
Read the addresses (account.addresses.get)GET /addresses—Accepted200—
Start an address change (account.addressChange.create)POST /address-changeStrongest factorRefused202disabled_by_realm
Confirm an address change (account.addressChange.confirm)POST /address-change/confirmStrongest factorRefused200invalid_code, not_found, disabled_by_realm

The apps the person consented to, and their revocation.

OperationRequestFresh authenticationAct-chain tokenSuccessIts own codes
List the connected apps (account.consents.list)GET /consents—Accepted200—
Disconnect an app (account.consents.delete)DELETE /consents/{consentId}—Accepted204not_found

Deleting the account.

OperationRequestFresh authenticationAct-chain tokenSuccessIts own codes
Preview the deletion (account.deletion.get)GET /deletion—Accepted200—
Delete the account (account.deletion.create)POST /deletionStrongest factorRefused204disabled_by_realm

The security notices the account receives.

OperationRequestFresh authenticationAct-chain tokenSuccessIts own codes
Read the notification preferences (account.notificationPreferences.get)GET /notification-preferences—Accepted200—

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.

KindWhat it tells the personBuilt from“This wasn’t me”
signed_inThe person signed in.user.signed_inYes
new_deviceThe 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_failedA 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 passwordNo
session_endedA session ended: a sign-out, one session ended from the console, or “sign out everywhere”.user.signed_outNo
password_changedThe account’s password was changed.user.password_reset, user.password_changed (with #685)No
credential_addedA passkey or an authenticator app (TOTP) was added to the account.user.credential_added (with #686)No
credential_removedA passkey or an authenticator app (TOTP) was removed from the account.user.credential_removed (with #686)No
credential_disabledThe password or the authenticator app was disabled after too many wrong attempts (ADR-017 Decision 7).user.credential_disabled (with #693)No
recovery_code_usedA recovery code was used in place of a second factor.user.recovery_code_usedNo
recovery_codes_regeneratedA new set of recovery codes replaced the old one.user.recovery_codes_regenerated (with #687)No
recovery_codes_failedRecovery codes were tried and refused 100 times (ADR-017 Decision 7).user.recovery_codes_failedNo
send_ceiling_reachedCodes 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_changedThe address the account signs in with, or its contact address, changed.user.identifier_changed (with #690)No
account_linkedA wallet or an upstream account was linked to the account.wallet.linked, user.upstream_linked (with #670)No
account_unlinkedA wallet or an upstream account was unlinked from the account.user.upstream_unlinked (with #670)No
consent_grantedThe person let an app access the account.consent.grantedNo
consent_revokedAn app’s access to the account was withdrawn.consent.revokedNo

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.

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.

NoticeWhenMandatory
new_deviceA sign-in from a device QAuth had not seen for the account.No
password_changedThe password was changed.Yes
credential_disabledThe password or the authenticator app was disabled after too many wrong attempts. It carries a reset pass.Yes
credential_addedA passkey or an authenticator app was added.Yes
credential_removedA passkey or an authenticator app was removed.Yes
recovery_code_usedA recovery code was used.Yes
recovery_codes_regeneratedThe recovery codes were regenerated.Yes
recovery_codes_failedRecovery codes were tried and refused 100 times.No
send_ceiling_reachedCodes were requested past the per-address send ceiling. It carries a reset pass.Yes
second_factor_failedA wrong second factor followed a correct password.Yes
identifier_changedThe sign-in address or the contact address changed; sent to the old address.Yes
account_deletedThe account was deleted.Yes

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.

CodeStatusMeaning
bad_request400The request is malformed and no more specific code applies.
validation_error400The request does not match the schema of the operation. errors names each field and why.
invalid_cursor400The cursor does not parse, or it belongs to another list.
invalid_current_password400The current password a password change names is wrong. It counts on the account’s failure ladder, as a failed sign-in does.
invalid_code400A 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.
unauthorized401The request carries no access token. WWW-Authenticate names the scheme and no error.
access_token_expired401The access token has expired.
invalid_access_token401The access token is not valid, is not valid for this operation, or names no active user.
invalid_dpop_proof401A 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_authentication401The 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.
forbidden403The token lacks a scope the operation needs.
delegated_token_refused403The 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_realm403The 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_found404The resource does not exist, or belongs to another account: the answer is the same.
conflict409The request conflicts with the current state of the account.
last_credential409The change would leave the account with no way to sign in, such as deleting the only passkey of a passkey-only account.
second_factor_required409The change would remove the last second factor while the realm’s policy requires one for this account.
precondition_failed412The If-Match value does not match the current ETag of the resource.
content_too_large413The request body is larger than the server accepts.
unsupported_media_type415The media type of the request body is not one the operation accepts.
misdirected_request421The server does not answer for the host the request names.
unprocessable_content422The 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_password422The new password does not meet the realm’s password policy. reasons lists why.
precondition_required428The update needs an If-Match header.
too_many_requests429Too many requests. Retry-After says when to try again.
internal_error500The server failed. trace_id finds the failure in the server log.
service_unavailable503The 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.

  • The path and the audience carry the major version. Until QAuth 1.0.0 it is a release candidate, /account/v1-rc.<M> and urn: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.