Skip to content

ADR-017: First-Party Login — Headless Sign-In on the Authorization Challenge Endpoint

This is a rendered copy of docs/adr/017-first-party-login.md, read in place from the repository — see therecords index for how to read it. If this page and the source file ever disagree, the source file is correct.

ADR-017: First-Party Login — Headless Sign-In on the Authorization Challenge Endpoint

Status: Accepted Date: 2026-09-26 Authors: QAuth Team

Proposed 2026-09-26, accepted 2026-10-11. Nothing below is implemented. Every fork takes the fail-closed option. The authorization challenge endpoint sits behind FIRST_PARTY_LOGIN_ENABLED (default false): with the switch off, the route is not registered and discovery does not mention it. F0 makes a few flag-free changes before the endpoint exists; each is a listed delta with its own test (Phasing). The client profile this record adds is inert for every client an operator has not flagged, so for those clients nothing else changes.

Amended 2026-10-09 (before any implementation) with the maintainer’s decisions of 2026-10-08 and 2026-10-09. The sections they touch are edited in place.

  • Web front-ends. On 2026-10-08 the maintainer answered parked question 1. Web front-ends use the redirect flow. The FiPA endpoint (the authorization challenge endpoint) serves first-party native apps only. The question now sits under “Decided by the maintainer”.
  • One engine, two transports. The F1 engine is shared with the Interaction API that ADR-019 defines. One step grammar (Decision 3) has two transports: the Interaction API for browser ceremonies and the FiPA endpoint for native apps. F2a’s hosted pages live in the reference ceremony app, a separate app that uses only the Interaction API.
  • Stability. The FiPA endpoint and the first-party subpath of @qauth-labs/node stay experimental until FiPA is an RFC. The engine is part of the 1.0 promise, because the hosted pages use it (ADR-018).
  • The portal’s tokens. F0 also moves the developer portal’s tokens server-side. The portal cookie then carries only a session id.
  • Names. ADR-014’s tree mechanism is now the Authority Tree. AGENT_TREE_ENABLED becomes AUTHORITY_TREE_ENABLED, and AGENT_APPROVAL_ENABLED becomes REMOTE_APPROVAL_ENABLED. File names, and so link targets, are unchanged.

Amended 2026-10-09 (questions 2–30). The maintainer decided the remaining parked questions on 2026-10-09. They now sit under “Decided by the maintainer”, with their numbers kept. The sections they touch are edited in place.

  • Questions 2–10. Emailed codes are never a sign-in factor. The check after the password exists only at the FiPA endpoint. The import tool stays, for Argon2id hashes only. Other migrated users sign in through upstream OIDC login with their old provider.
  • Hosted pages. The reset, verification and account pages are on whenever hosted pages are on. Only sign-up is off by default. So the disable ceiling is armed by default.
  • Scripts get management tokens from an operator-listed public CLI client, before 1.0. “Sign out everywhere” also sends back-channel logout, and it is on the hosted account page.
  • PKCE S256 stays mandatory on the redirect flow. At the FiPA endpoint it is mandatory by default, and an operator may switch it off per client. From F3, DPoP is on by default for native_backend clients, with the same per-client switch-off.
  • Registration uses the standard prompt=create from F1. It needs both the realm’s registration_allowed and the client’s flows.
  • /auth/login and the headless hand-off are removed with no freeze phase, before 1.0.
  • /auth/register is removed before 1.0 too. This is an explicit exception to the rule that a legacy route stays while its replacement is off by default. Its replacement, the hosted sign-up page, exists but is off by default (question 6). An operator who wants sign-up turns it on. The portal then signs up through that page by redirect.
  • The bot challenge and passkey-only accounts are in 1.0. F4 joins 1.0 only if the attestation draft becomes an RFC first.
  • Alignment with the 1.0 decisions (ADR-018 and ADR-019). TOTP moves from F5 into the 1.0 train, as the new phase F2c. The browser-session reset counter keeps Postgres as its source of truth, with a Redis cache. Back-channel logout sits beside the end-session route. The passkey RP ID defaults to the realm’s exact host.
  • Gates now speak of a verified account, not a verified address. REQUIRE_EMAIL_VERIFIED becomes REQUIRE_VERIFIED_ACCOUNT, and the old name stays as a deprecated alias.

Amended 2026-10-19 with the maintainer’s decision of that day on ANT-2026-SPEMY9GU, a report that /auth/register told any caller whether an address has an account. The maintainer chose to keep the threat model’s promise rather than narrow it until F1c removes the route. The original text below is unchanged; where it says the gate defaults to false, read true.

  • REQUIRE_VERIFIED_ACCOUNT defaults to true. The alias keeps working, and a blank value still reads as unset.
  • /auth/register answers every address the same way: 202, one fixed message, the same headers, no user id, the same work on the request path under a minimum response time. An address that already has an account gets no new one; its owner gets a notice by mail, or, while the account is unverified, its verification link again (verification already needs the account’s password as well as the token). That mail respects the resend-verification caps and is never awaited by the request.
  • /auth/login and /ui/login refuse an unverified account exactly as a wrong password, so registering an address and then logging in with the chosen password no longer tells a free address from a registered one. This changes no decision of this record: the FiPA endpoint still verifies an unverified account after a correct password (Decision 8), and /auth/register is still removed in F1c.

Amended 2026-10-19 with the maintainer’s decision of 2026-10-11 on the bot challenge, which question 15 had left provider-neutral. Decision 7, the captcha step of Decision 3 and F1b are edited in place; ADR-019 Decision 5 carries the matching page rules.

  • The default provider is a proof-of-work challenge built into QAuth. The reference ceremony app serves its script from the realm origin, so the strict CSP needs no exception for it.
  • An operator may add their own hosted provider, and name script hosts for it. Those hosts apply to the hosted sign-up and sign-in pages only, and the list is empty by default.
  • By choosing a hosted provider, the operator accepts that its script runs on those pages.
  • A required challenge needs JavaScript on the hosted pages.

Context

Many first-party apps want native sign-in and sign-up: the user types credentials into the app’s own screen, the app’s backend talks to the identity provider (IdP), and no browser opens. The common way to get that is the resource owner password credentials grant (ROPC): an app that calls a password grant at another IdP sends a username and a password and receives tokens. RFC 9700 §2.4 “Resource Owner Password Credentials Grant” says the grant “MUST NOT be used”. ROPC also drops the IdP’s own controls (MFA, step-up, brute-force defence, account verification), so each app rebuilds them on its own. QAuth should offer the same experience without ROPC and without a browser, as a headless, standards-based first-party login, and give apps that use a password grant a clear path off it.

What exists and what this record adds (verified 2026-09-26)

ConcernAs of 2026-09-26This record adds
Password-to-token routePOST /auth/login (routes/auth/login.ts) exchanges { email, password } for tokens of the internal system client (getOrCreateSystemClient, helpers/oauth-client.ts), without client authentication. /oauth/token offers no password grant.Removed in F1c, before 1.0, with no freeze phase; the authorization challenge endpoint replaces it (Decision 12)
QAuth’s own appThe developer portal (apps/developer-portal/src/server/auth-server-client.ts) calls /auth/login, /auth/register, /auth/verify, /auth/resend-verification and /auth/logout server-to-serverIn F0 the portal signs in through /oauth/authorize and keeps its tokens server-side; from F2a it signs up on the hosted sign-up page, by redirect, while sign-up is on (Decision 12)
Headless hand-off/oauth/authorize accepts Authorization: Bearer with a system-client access token in place of a browser sessionFollows the consent rule from F0; removed in F1c, before 1.0 (Decision 9)
Client authenticationprivate_key_jwt at /oauth/token (ADR-011 §7 “private_key_jwt (#384) — additive, no flag”); /oauth/revoke and /oauth/introspect accept client_secret_basic and client_secret_postprivate_key_jwt at both in F0 (Decision 14)
First-party markingNo client attribute marks a client as first-party. Operator-set attributes such as max_agent_mode are written only by the seed manifest (clientSpecSchema)An operator-set first_party_profile, written only by the seed manifest or an admin:security operation, and CHECK-constrained (Decision 2)
ConsentSkipped when an active consent row covers the requested scopes (canSkipConsent, helpers/consent.ts)Audited administrative consent for flagged clients (Decision 9)
Authorization codesTwo mint sites; every code is S256 PKCE-bound; authorization_codes.redirect_uri is NOT NULL and the token endpoint requires redirect_uriOne mint helper, an issued_via marker, and codes without a redirect URI (Decision 10)
Sender constraintNo DPoPDPoP in F3, one verifier shared with ADR-014 (Decision 14)
acr and amracr only from wallet sign-in (ADR-010); no amramr and auth_time on tokens from challenge-issued codes (Decision 10)
Password policyzxcvbn score ≥ PASSWORD_MIN_SCORE (default 2), with PASSWORD_MAX_LENGTH (256) checked first, applied by /auth/register (libs/shared/validation/src/lib/password.ts)One policy for every newly set password, per NIST SP 800-63B-4 §3.1.1.2 “Password Verifiers” (Decision 8)
Registration, verification, reset/auth/register, /auth/verify and /auth/resend-verification JSON routes; no password-reset routeAll three inside the authorization challenge endpoint (F1); hosted pages (F2a) (Decision 8); /auth/register removed in F1c, before 1.0 (Decision 12)
Factors and challengesPassword; wallet sign-in behind WALLET_FEDERATION_ENABLED; no TOTP, passkey, emailed code or bot challengePasskeys (F2b, Decision 14) and TOTP (F2c) as factors; emailed codes for verification, recovery and an optional per-client check after the password, never as a factor (F1, Decision 5); a bot challenge (F1b, Decision 7)
Abuse controls@fastify/rate-limit keyed on the TCP peer (request.ip); failed-login counters in helpers/failed-login.ts; TRUST_PROXY as an address or CIDR listA layered limiter, a failure ladder per NIST SP 800-63B-4 §3.2.2 “Rate Limiting (Throttling)”, and an asserted end-user IP (Decision 7)
Client SDKNone; the README lists @qauth-labs/node as planned@qauth-labs/node in F1 (Decision 13)
Discoveryauthorization_response_iss_parameter_supported: true; no authorization_challenge_endpointMembers that appear only while the endpoint accepts them (Decision 11)

The draft this record builds on

draft-ietf-oauth-first-party-apps-04 (“OAuth 2.0 for First-Party Applications”, here “FiPA”) is dated 2026-07-01 and expires 2027-01-02. The Working Group Last Call (WGLC) on -03 ran from 2026-06-25 to 2026-07-13. The chairs’ IETF 126 slides, uploaded 2026-07-22, list the draft as “Waiting for Shepherd Write-up”. The IETF Internet-Draft index generated 2026-09-26 gives its IESG state as “I-D Exists”. No text newer than -04 existed on 2026-09-26.

FiPA defines the authorization challenge endpoint at the authorization server (AS). A client posts what it collected from the user and receives an authorization code or an error. The error asks for more (insufficient_authorization, HTTP 403), sends the user to a browser (redirect_to_web, HTTP 403), or ends the attempt (invalid_session). An opaque auth_session handle links the rounds, and the code is redeemed at the ordinary token endpoint. FiPA does not define the steps. FiPA §1.2 “Limitations of this specification”: “The precise client–server interactions used to authenticate the user (e.g., prompts, challenges, and step sequencing) are intentionally left to individual deployments and are out of scope for this specification”, and “This specification is intended to be profiled to standardize specific interaction patterns enabling a complete interoperable solution.” OpenID for Verifiable Credential Issuance 1.1, an OpenID Foundation (OIDF) editor’s draft, profiles -04 in its section “Interactive Authorization”. Four Working Group (WG) issues about the wire are open as of 2026-09-26: WG issue #171 (collisions), WG issue #173 (negotiation), WG issue #177 (status codes) and WG issue #179 (redirect_uri).

Four readings this record corrects

  1. Web front-ends behind a backend. A natural reading of the heading of FiPA §9.8 “Single Page Applications” suggests that it does not reach a web app whose OAuth work runs in a backend (a BFF). The text says “it is NOT RECOMMENDED to use this specification in browser-based applications”. RFC 10017 (“OAuth 2.0 for Browser-Based Applications”, a BCP of August 2026) §3 “Terminology” defines a browser-based application as one “dynamically downloaded and executed in a web browser”, and §6.1 “Backend for Frontend (BFF)” describes “a browser-based application that relies on a backend component”. RFC 10017 §7.3 “Resource Owner Password Credentials Grant” says “browser-based applications using OAuth or OpenID Connect MUST use a redirect-based flow”, and §6.1.3.1 “The Authorization Code Grant” says the BFF “MUST use the OAuth 2.0 Authorization Code grant”. A backend that serves a native app is not a browser-based application in that sense, and FiPA §1.1 “Usage and Applicability” says the draft “is designed to be used by first-party native applications, which includes both mobile and desktop applications”. So this record serves native front-ends only (Decision 1; question 1, decided 2026-10-08).
  2. PKCE (RFC 7636). A natural reading suggests that FiPA requires PKCE, as OAuth 2.1 does. The text of FiPA §5.1 “Authorization Challenge Request” makes code_challenge and code_challenge_method OPTIONAL. QAuth requires S256 by default, a stricter choice (Decision 3). An operator may switch it off for one client at this endpoint only (question 11).
  3. FiPA §1.1. A natural reading suggests that §1.1 obliges every deployment to describe how it avoids third-party risks. The text binds only “Profiles of this specification that extend the usage to non-first-party use cases”; for other uses its sentence is descriptive (“Using this specification in scenarios other than those described may lead to unintended security and privacy problems for users and service providers.”). So QAuth states its profile rules as its own requirements (Decision 1).
  4. Email codes. A natural first design would use email codes as a sign-in factor. NIST SP 800-63B-4 §3.1.3.1 “Out-of-Band Authenticators” says “Email SHALL NOT be used for out-of-band authentication”, and “Confirmation codes that are sent to validate email addresses or are issued as recovery codes (see Sec. 4.2.1.2) are not authentication processes and not affected by the above prohibition.” So QAuth uses emailed codes to verify addresses and to recover, never as a sign-in factor (Decision 5).

Threat model

ThreatControlResidual
Credential stuffing and spraying through a first-party backendPer-account ladder (delay, bot challenge from F1b, disable at 100), with every counter reserved before the verifier runs; per-end-user-IP limits and a distinct-identifier count per IP bucket; a per-client failure-ratio breaker that counts each account’s success once, and an absolute per-client failure budget; per-session budget, dummy Argon2id verify and a timing floor, optional post-password email check, attestation as a bot signal (F4)A correct password is learned when a second step appears. Successful sign-ins do not dilute the breaker: each account’s success counts once per window, and the absolute failure budget fires whatever the ratio. A campaign kept below every threshold is bounded by them: at the defaults, 300 failed rounds per 5 minutes per client before the bot challenge (about 86,000 a day), spread over IP buckets that each fail on at most 20 identifiers per 15 minutes, with 5 free tries per account.
Phishing by a look-alike appOnly operator-flagged confidential backends reach the endpoint; public clients refused until attestation and DPoP (F4); passkey-first (F2b); redirect_to_web on a risk rule (none in F1)A look-alike app can collect a password and relay an email code; from F2b a password alone no longer completes a login on a passkey-bound account; manually entered codes are not phishing-resistant (NIST SP 800-63B-4 §3.2.5 “Phishing Resistance”)
Client impersonation and assertion misuseprivate_key_jwt on every round at the authorization challenge endpoint, with aud = the issuer as sole value, a life of at most 60 s and a burned jti; public clients refused until F4A stolen backend key impersonates the backend until the operator rotates its keys
Scripts calling the vendor’s backendAsserted end-user IP, per-client breaker, per-account ladder; app attestation between app and backend is the vendor’s job until F4QAuth cannot tell a real app from a script behind a real backend before F4
auth_session theft or replay256-bit handle, hashed at rest, consumed atomically, rotated every round, bound to client authentication, 10-minute absolute life, dead at the code, never logged; a replay ends the live transactionA backend that mixes its own users’ transactions (the SDK enforces one per app session)
Account enumerationUniform first response; identical registration and reset bodies; counters keyed on an identifier HMAC for known and unknown addresses; a per-address-and-IP send tier met by quiet suppression; a per-address-and-client tier and a per-address send ceiling that count unknown addresses too and answer every address with the same 429; dummy hash; timing floor on every identifier-resolving roundEmail content differs; only the mailbox owner sees it
Lockout as denial of serviceSoft ladder first, reserved by every surface before it verifies; known-IP exemption from the wait; disable only at 100 counted failures and only while the owner has a rebind path that a third party cannot exhaust (no quiet suppression at the per-address send ceiling, a known IP of a verified account never meets the per-address tiers, a pass past the per-address tiers in the disable and send-ceiling notices, which a realm cannot turn off); notice to the userA per-account wait also delays the real user from an unknown IP. An attacker who knows an address can wait out the delays (or solve bot challenges from F1b) and disable its password after 100 counted failures, in under a day with the default waits. The user then needs a reset, which the attacker cannot silence: a known IP of a verified account still gets the mail, and a user on an unknown IP who meets the send ceiling’s 429 starts the reset from the pass in the notice; the operator is alerted. The per-client hard cap and per-IPv4 limits affect every user behind them. A third party who sends about 300 failed rounds in 5 minutes through a client puts every user of that client behind the delay or the bot challenge; the operator is alerted.
Forged end-user IPIP inside the signed, single-use assertion; derived by the backend from its peer or a listed proxy chain (the SDK does it); audit rows mark it asserted; TCP peer and client_id keep their own capsQAuth verifies who asserted the value, not the value; a misconfigured backend skews per-IP limits and audit rows; a forged or shared IP that matches a known IP skips the per-account wait (it still counts toward the disable ceiling) and the per-address send tiers, which also shows that the account exists
Script on a web origin driving a backendWeb front-ends outside the profile; browser-origin guard; no CORS; a signed assertion on every requestAn operator can flag a web backend as native. Script running in that web front-end could then drive the backend’s login actions. RFC 10017 §5.1.4 “Proxying Requests via the User’s Browser” says this scenario “cannot be stopped or prevented by application-level security measures”
Code interception, replay or AS mix-upPKCE S256 required by default (an operator may switch it off per client at this endpoint); 60-second single-use codes; a replay revokes the family; iss on every response; the SDK pins one issuer and its metadataRaw-wire clients that ignore iss
Downgrade and factor mixingA transaction completes only after the account’s strongest bound factor; one subject per transaction; an email code never replaces an authenticator; /auth/login refuses passkey-bound accounts while it exists; the realm’s and client’s MFA policy is enforced after the first factor on every transport; a disabled second factor still counts as bound, and email never replaces a lost oneUntil the hosted /ui/login runs on the same engine (F2b), the browser path keeps its own rules
Second-factor guessing with a known passwordOne counter per account and second-factor type across transactions, clients, transports and IPs, never cleared by a correct first factor; the ladder from 5 failures; TOTP disabled at 100 while the hosted pages are on and the account keeps an unused recovery code, or while it keeps another usable second factor; a notice to the user on a wrong second factor after a correct password; binding the first passkey or TOTP of a password-only account also needs a code mailed to its address; recovery codes of at least 64 bits; a TOTP time step accepted onceAt most 100 consecutive TOTP guesses between two sign-ins by the owner, who is notified of a wrong second factor after a correct password: about 0.03 % at three valid codes in 10^6 per guess, and reaching 100 takes about 21.5 hours whatever the number of IPs. The owner then recovers with a recovery code. Each sign-in by the owner starts a new run, worth about 13 more guesses than the steady ladder, until the notified owner changes the password. An account without such a rebind path keeps the ladder alone: about 96 guesses a day plus about 13 per sign-in by the owner, about 0.03 % a day at one sign-in a day
Account takeover through a mailbox8-digit recovery code, 10 minutes, 5 tries per code, a per-identifier wait ladder across codes (bot challenge from F1b), send caps; a stronger bound authenticator required; every refresh family revoked, live transactions and browser sessions ended; notice sentMailbox takeover of a password-only account is an account takeover; codes can be relayed in real time; an attacker who waits out the ladder can keep guessing slowly, and every code guessed at was also mailed to the user
Registration abuse (squatting, pre-hijacking, mail bombing)Verify first, then create; the first mailbox proof on an unclaimed account ends that account and creates a new one with a new sub, so nothing bound before the proof carries over; send caps per address, IP and client; per-client opt-in; bot challenge from F1bBot sign-ups with real mailboxes before F1b; downstream apps keep state for the ended sub until they act on its back-channel logout, and none of it reaches the new account; where hosted sign-up is off, a third party can delay one new address’s native sign-up by holding it at its send ceiling, which the 429 and the operator alert make visible
Third-party, MCP or agent client; consent waived for the wrong clientCHECKs exclude DCR, CIMD and agent clients; the waiver covers only flagged clients and non-dangerous allowlisted scopes; flags written only by the seed manifest or an admin:security operation, each with a written reason; removing a flag revokes familiesOperator error in the seed manifest, or by an admin who holds admin:security
Browser-leg session swapApp-claimed redirect URI; completion only over the originating app session with a matching state; a fresh login that QAuth requires for every /oauth/authorize request of a native profileThe app-side half is vendor code until the mobile SDK (F4)
Bearer access-token theft from a backendTokens stay on the backend; short access-token life; refresh tokens bound to the clientThe deviation from FiPA §9.5 “Sender-Constrained Tokens” until F3, where DPoP is on by default; afterwards only for a client whose operator switched DPoP off

The question this ADR settles

FiPA gives a frame: an endpoint, three error codes and an opaque handle. It leaves the steps, their signalling, first-partyness and abuse controls to each deployment. This record settles which clients may call the endpoint, what the wire carries, how steps are ordered and throttled, how registration and reset fit in, how apps leave a password grant, and what QAuth does with its own password-to-token route.

Decision

One switch gates the endpoint: FIRST_PARTY_LOGIN_ENABLED, a boolean in the auth environment schema, default false. Phases are named F0 to F5. Some have lettered sub-phases (F1b, F1c, F1d, F2a, F2b, F2c), and Phasing lists them all. These names are distinct from ADR-014’s P0–P5.

1. Scope — a first-party-only endpoint for native front-ends, behind one switch

QAuth adds the FiPA -04 authorization challenge endpoint at POST /oauth/authorize-challenge, behind FIRST_PARTY_LOGIN_ENABLED, for one client shape in the first release: an operator-flagged confidential backend (private_key_jwt) whose user-facing front-end is a native mobile or desktop app. Web front-ends use the redirect-based code flow. The maintainer decided this on 2026-10-08 (question 1).

With the switch off, the route is not registered (404), discovery omits every member this record would add, and flagged rows do nothing at the endpoint. The profile is the confidential backend for a native front-end, value native_backend; the value avoids the word BFF, because RFC 10017 §6.1 “Backend for Frontend (BFF)” makes a BFF part of a browser-based application.

QAuth’s profile requirements, stated as QAuth’s own rules:

  1. The backend is a confidential client with private_key_jwt; its key stays server-side.
  2. It keeps one auth_session per app-side session, server-side, never on the device, discarded at logout.
  3. It puts the end-user IP in every client assertion (Decision 7), derived from the app connection’s peer address or a proxy chain it operates and lists, never from a free-form header.
  4. It exposes fixed login actions to its app, never a generic proxy to QAuth (by analogy with RFC 10017 §6.1.3.6 “Proxy Restrictions”).
  5. For redirect_to_web, the app opens the system browser or an in-app browser tab, never a web view (RFC 8252 §8.12 “Embedded User-Agents”).
  6. The browser leg returns to the app and completes only over the originating app session. Its redirect URI is app-claimed https on mobile (RFC 8252 §7.2 “Claimed “https” Scheme URI Redirection”) and private-use or loopback on desktop (§7.1 “Private-Use URI Scheme Redirection”, §7.3 “Loopback Interface Redirection”), never a URL the backend serves. The app posts code, state and iss to its backend over the same app session that started the leg, and the backend redeems the code only if state was issued to that session (RFC 8252 §8.9 “Cross-App Request Forgery Protections”). That app session exists before the user signs in: the backend issues it to the app instance, and the SDK’s sessionKey names it. The leg carries prompt=login, and QAuth applies it to every /oauth/authorize request of a native profile whatever the request says (Decision 9). A backend that completed from state alone could not tell which browser returned the code; with the consent waiver and a live QAuth browser session, a response could attach to the wrong app session.
  7. It checks iss on every response and uses only endpoints from its pinned issuer’s metadata.
  8. Tokens stay on the backend; logout revokes the refresh token at /oauth/revoke.
  9. Every first-party app renders the same steps; the SDK’s step contract carries this.
  10. Until attestation (F4), the genuineness of the app in front of the backend is the vendor’s responsibility; QAuth does not see it.

QAuth enforces three things. A request that carries Origin or Sec-Fetch-Site is refused with 400 invalid_request and audited (Sec-Fetch-Mode is not a signal: Node’s built-in fetch sends it without either of the other two). The route has no CORS, by analogy with RFC 9700 §2.6 “Other Recommendations”: “CORS MUST NOT be supported at the authorization endpoint”. Every request carries a signed client assertion. Together they stop browser script from calling the endpoint; they cannot stop a web backend’s server. The browser-origin guard sits in this endpoint’s transport only, never in the shared engine (question 26, decided 2026-10-09). The Interaction API serves browsers.

Why. FiPA §9.8 and RFC 10017 §7.3 and §6.1.3.1, as in Four readings this record corrects. FiPA §1 “Introduction”: the draft “should be considered only when a redirect-based approach introduces usability issues”, which holds for a native app and not for a web page. FiPA §5 “Authorization Initiation” asks for native-app best practice “for redirect URI selection and external user-agent usage”. ADR-008 §3 “Fail-safe defaults (the safe method)” sets the default-off switch.

2. First-partyness — an operator-set client profile, never self-asserted

First-partyness is an operator-set oauth_clients.first_party_profile, written only by the seed manifest or an admin:security operation (ADR-019 Decision 7) and tied by database CHECKs to private_key_jwt, non-DCR, non-CIMD, non-agent, developer-less rows. Public native clients are refused until attestation-based client authentication and DPoP both exist. DPoP proves key continuity, not app identity.

FiPA §1.1 and §9.1 “First-Party Applications” define first-party apps as “controlled by the same entity as the authorization server” and that “users understand as belonging to the same entity”. An operator may set a profile only for an app that the entity operating the AS controls and that users see under that entity’s brand; other entities’ apps, including tenants of a shared deployment, use /oauth/authorize. Each seed entry carries a required written reason, stored with the client and audited.

-- F0 migration on oauthClients (libs/infra/db/src/lib/schema/core.ts)
first_party_profile enum('web_redirect','native_backend') NULL -- 'native_attested' added in F4
first_party_policy jsonb NULL -- native profiles only: { flows, email_code_after_password, pkce_required }
CHECK (first_party_profile IS NULL OR (dynamic_registered_at IS NULL AND developer_id IS NULL
AND is_agent = false AND coalesce(metadata->>'registrationType','') NOT IN ('dynamic','cimd')))
CHECK (first_party_profile IS NULL OR token_endpoint_auth_method = 'private_key_jwt')
CHECK ((first_party_profile = 'native_backend') = (first_party_policy IS NOT NULL))
CHECK (first_party_profile IS DISTINCT FROM 'native_backend' OR grant_types ? 'authorization_code')
-- F4 adds 'native_attested' and replaces the auth-method CHECK: 'web_redirect' and
-- 'native_backend' stay tied to private_key_jwt; 'native_attested' is tied to
-- attest_jwt_client_auth[_dpop]. F4 also widens the policy and grant CHECKs to
-- ('native_backend','native_attested').

web_redirect earns only the consent waiver on /oauth/authorize (Decision 9) and is refused at the new endpoint; native_backend reaches it; native_attested (F4) is an attested native app. No value admits a public (none) or secret-based client in any phase. first_party_policy is a strict object: flows (a non-empty subset of login, register, reset; default login), email_code_after_password (default false) and pkce_required (default true); a policy that does not parse makes the client not first-party. One new resolver, resolveFirstPartyProfile(client), serves the endpoint gate and the consent waiver. Write paths follow the pattern of max_agent_mode: the seed manifest (clientSpecSchema, buildInsert, buildUpdate in libs/infra/db/src/scripts/seed-oauth-clients.ts) is the only positive write path at deploy time; DCR pins NULL; CIMD omits the fields; the developer API (createClientRequestSchema, updateClientRequestSchema) can read but not write them. Once the declarative realm file (ADR-018) and the admin API (ADR-019) can carry these fields, the only other write path is a security operation of ADR-019 Decision 7. Setting, changing or removing first_party_profile or first_party_policy, setting pkce_required: false and switching DPoP off for a flagged client each need admin:security, a fresh passkey approval bound to that operation, a second admin where the realm requires one, and a written reason stored with the row. Each writes an admin event with its diff. admin:write, and the console’s client screen under it, can read these fields but not write them. A realm-file import that would change them is refused unless the importer meets the same rule, and its dry-run diff lists them apart from other changes. The CHECKs hold on every path. For native profiles the seed requires jwks or jwks_uri, authorization_code, a reason, and at least one browser-leg redirect URI that is https, private-use or loopback. The seed cannot check that an https URI is app-claimed and never served by the backend. That stays a profile requirement (Decision 1, requirement 6). Removing a profile revokes every refresh family of the client, audited.

PKCE S256 stays mandatory on the redirect flow, always. At this endpoint it is mandatory by default. An operator may set pkce_required: false for one client in the seed manifest, or through the security operation above. QAuth then logs a warning at boot that names the client. The standards position records the deviation. The maintainer decided this on 2026-10-09 (question 11).

The runtime gate runs after client authentication on every round (client enabled, profile native_backend or, from F4, native_attested, authorization_code granted, flow in flows, and for registration the realm’s registration_allowed); failure is 400 unauthorized_client, audited. Client authentication uses authenticateClientRequest and authenticateClientAssertion with three stricter rules. aud is the issuer identifier as its sole value (a string or a one-element array), so the token endpoint URL is refused here and, from F0, at /oauth/revoke and /oauth/introspect. Since 2026-10-11 it is refused at /oauth/token for every client too (ADR-011 §7), so a client with a first_party_profile needs nothing extra there. Flagged rows exist only from F0. iat is required and exp − iat is at most 60 s; with CLIENT_ASSERTION_CLOCK_SKEW_LEEWAY_SECONDS (60) on exp, an assertion is accepted up to about 120 s after iat, and the burned jti keeps it single-use. The assertion must carry qauth_end_user_ip (Decision 7), or the answer is 400 invalid_request. The SDK always sets typ: client-authentication+jwt; the server does not require it.

Public native clients get 400 unauthorized_client in every environment until F4, for three reasons. RFC 8252 §8.5 “Client Authentication” says an AS must not accept a secret embedded in a native app “as proof of the client’s identity”. FiPA §9.4 “Client Authentication” says “client impersonation should be a concern of anyone deploying this pattern”. A fake app can also generate a DPoP key. From F4, native_attested uses attestation -11 in DPoP combined mode (§5.2 “Using DPoP as the Proof of Possession”), with attester keys set per client by the operator (§10.8 “Trust Management and Key Resolution”).

Why. FiPA §5 “Authorization Initiation”: the AS “MUST verify the “first-partyness” of the client before continuing with the authentication flow”. FiPA §9.1 is “not prescriptive” on how and names DCR only as a possible tool, so refusing DCR and CIMD clients is QAuth’s stricter choice. ADR-008 §4 “Relaxation is operator-controlled, never self-asserted”; ADR-011 §5 “Why an allowlist, and not the alternatives”; ADR-012 §4 “The developer-scoped predicate is never widened”. RFC 9700 §2.5 “Client Authentication” recommends asymmetric methods, and draft-ietf-oauth-rfc7523bis-11 §4 “Updates to RFC 7523” requires the issuer as the sole audience of a client assertion.

3. The wire — FiPA -04, OpenID4VCI negotiation and namespaced QAuth steps

QAuth signals every next step as FiPA’s HTTP 403 insufficient_authorization plus OpenID4VCI 1.1’s interaction_type_required. The step is negotiated from the client’s interaction_types_supported. QAuth step identifiers sit under urn:qauth:ia:, and every QAuth-defined member is prefixed qauth_. No QAuth value ever appears in error. No prose goes on the wire. Every response carries iss.

The urn:qauth:ia: step names become stable through the Interaction API’s /interaction/v1 (ADR-018 and ADR-019). If a registry of standard step names appears, its names are added as aliases. QAuth never renames a step. The maintainer decided this on 2026-10-09 (question 18).

The envelope. POST {issuer}/oauth/authorize-challenge, https only. Every request, initial and intermediate, is application/x-www-form-urlencoded, UTF-8; a JSON-valued answer (a WebAuthn response) travels as a JSON string in one form parameter. Every response is application/json with Cache-Control: no-store, sets no cookie, and carries iss (RFC 9207 §2 “Response Parameter iss”). iss on the 200 is a QAuth addition: FiPA §5.2.1 “Authorization Code Response” defines only authorization_code, and OpenID4VCI issue #595 describes an AS mix-up against these endpoints. Every request carries client authentication; client_id is optional and must match if sent. No prose: error_description is never sent for end users, and the AS never says why it asked for a step, how many tries remain, whether an identifier exists, or which factors an account has.

Initial-request parameterHandling
response_typecode required; otherwise 400 unsupported_response_type
code_challenge, code_challenge_methodrequired, S256, bounds as in authorizeQuerySchema, unless the client’s pkce_required is false (Decision 2); stricter than FiPA §5.1, where both are OPTIONAL; keeps a future request_uri legal (FiPA §5.2.2.1.1 “Redirect to Web Error Response”)
scope, resource, nonceas at /oauth/authorize
stateaccepted and bounded as at /oauth/authorize, as FiPA §4.1 “Authorization Challenge Endpoint” requires (“MUST accept the authorization request parameters”); otherwise unused: the authorization challenge endpoint never redirects, so no response carries it; the browser leg sends the app’s own state (Decision 1)
login_hintat most 254 bytes, email syntax, then normalizeEmail; the identifier answer; new handling (authorizeQuerySchema has no login_hint as of 2026-09-26)
max_agesatisfied, because every sign-in is a fresh authentication; auth_time is when the last authenticator succeeded; reset and registration are not offered (403 redirect_to_web if selected)
promptlogin a no-op (and, like max_age, rules out reset and registration); consent 403 redirect_to_web; none 400 login_required (OpenID Connect Core 1.0 §3.1.2.6 “Authentication Error Response”); create starts registration (Decision 8); others 400 invalid_request
acr_values400 unmet_authentication_requirements (Decision 4)
redirect_uriaccepted if redirectUriMatchesRegistered agrees; used for the browser leg and stored for the token-endpoint rule (Decision 10)
interaction_types_supportedcomma-separated step URNs, at most 1024 bytes and 16 entries; unknown URNs ignored; if absent, 403 redirect_to_web, FiPA’s own continuation, so a generic FiPA client never dead-ends
any repeated parameter400 invalid_request (RFC 6749 §3.1 “Authorization Endpoint”)

The authorization-request parameters are frozen at round 0: an intermediate request that carries one gets 400 invalid_request, and the transaction stays live. A DPoP header is refused with 400 invalid_request until the endpoint implements FiPA §9.5.1 “DPoP: Demonstrating Proof-of-Possession” code binding and §9.6.1 “Auth Session DPoP Binding” (F3), and OAuth-Client-Attestation headers until F4, so no client believes its code is bound when it is not.

QAuth memberWhereMeaning
qauth_interaction_typeany requestthe step URN this request answers or selects; required when several were offered; with no answer field other than login_hint it selects a path (reset, resend)
interaction_type_required403 bodyalways a value the client listed
qauth_interaction_types_offered403 bodyevery step the AS accepts next, preferred first; the first equals interaction_type_required
qauth_interaction_params403 bodyone object keyed by step URN with only what each offered step’s screen needs
qauth_interaction_error403 bodyclosed set: invalid_credentials (any rejected password, code or passkey, for any account state), code_expired, password_rejected (with reasons), captcha_failed (F1b); a rejected answer is still 403 insufficient_authorization with a new auth_session
answer fieldsrequestunprefixed, scoped by step: login_hint, password, otp (the name of FiPA Appendix B.1 “Authorization Challenge Request Parameters”), new_password, captcha_token, webauthn_response
Step (urn:qauth:ia: + name)Phaseqauth_interaction_params entryAnswer fieldsamr
identifierF1{}login_hint—
passwordF1{}password (+ login_hint if none was given)pwd
email_codeF1purpose (verify_address, recovery, confirm_sign_in), length (6 or 8), expires_in, resend_afterotp; none = resend—
new_passwordF1min_length, max_length, optional reasonsnew_password—
password_resetF1a path marker, no data— (+ login_hint)—
captchaF1bone of: provider pow with challenge, difficulty; or a hosted provider’s name with site_keycaptcha_token—
webauthn_getF2bWebAuthn Level 3 request options JSONwebauthn_responsepop, mfa
webauthn_createF2bWebAuthn Level 3 creation options JSON, optionalwebauthn_response— (enrolment)
totpF2cdigitsotpotp
Situation (every body carries iss)HTTPerrorNotes
Code issued200—authorization_code; no auth_session (FiPA §5.2.1)
Next step, or a rejected answer403insufficient_authorizationnew auth_session, interaction_type_required, qauth_* (FiPA §5.2.2.1 “Error Codes”)
Continue in a browser403redirect_to_webno request_uri before F5 (FiPA §5.2.2.1.1)
Handle unknown, expired, completed, replayed, bound elsewhere; budget spent400invalid_sessionone body for every reason
Malformed or oversized input, PKCE missing where required, repeated or frozen parameter, browser origin, IP claim missing, a header the endpoint cannot bind400invalid_request—
Client authentication failed401, as at /oauth/tokeninvalid_clientRFC 6749 §5.2 “Error Response”
Not first-party, wrong profile, flow not allowed400unauthorized_clientcode from RFC 6749 §4.1.2.1 “Error Response”
Scope refused; response_type not code400invalid_scope; unsupported_response_typeRFC 6749 §4.1.2.1
acr_values present400unmet_authentication_requirementsRFC 9470 §5 “Authorization Response”; code defined by OpenID Connect Core Error Code unmet_authentication_requirements 1.0
prompt=none400login_requiredOpenID Connect Core 1.0 §3.1.2.6
Throttled429 + Retry-Aftertemporarily_unavailablenew auth_session if the transaction may continue
Counter, session or bot-challenge store unavailable503temporarily_unavailablefail closed (Decision 7)
DPoP proof missing or invalid; nonce needed (F3)400invalid_dpop_proof; use_dpop_noncewith DPoP-Nonce

Wire examples (CA stands for client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=eyJ…). The eager password, which is the one-call path:

POST /oauth/authorize-challenge HTTP/1.1
Content-Type: application/x-www-form-urlencoded
response_type=code&CA&scope=openid%20profile%20offline_access
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
&interaction_types_supported=urn%3Aqauth%3Aia%3Apassword%2Curn%3Aqauth%3Aia%3Aemail_code%2Curn%3Aqauth%3Aia%3Anew_password%2Curn%3Aqauth%3Aia%3Apassword_reset
&qauth_interaction_type=urn%3Aqauth%3Aia%3Apassword
&login_hint=alice%40example.com&password=correct%20horse%20battery%20staple%20x
HTTP/1.1 200 OK
{ "authorization_code": "uY29tL2F1dGhlbnRpY2F0aW9u", "iss": "https://auth.acme.example" }

The next block shows two exchanges. The first is the uniform 403 returned when the request carries no credential. It is the same for every account and for none. A wrong password, an unknown account and a disabled account add only "qauth_interaction_error": "invalid_credentials", after the same timing floor. The second is an email-code verify step after a correct password on an account that is not yet verified (a password account whose address is unproved), followed by its answer:

← 403 { "error": "insufficient_authorization", "iss": "https://auth.acme.example",
"auth_session": "Qm9vbGVhbi1ub3QtYS1yZWFsLWhhbmRsZS0yNTYtYml0cw",
"interaction_type_required": "urn:qauth:ia:password",
"qauth_interaction_types_offered": ["urn:qauth:ia:password", "urn:qauth:ia:password_reset"] }
← 403 { "error": "insufficient_authorization", "iss": "https://auth.acme.example", "auth_session": "p0Lr…",
"interaction_type_required": "urn:qauth:ia:email_code", "qauth_interaction_types_offered": ["urn:qauth:ia:email_code"],
"qauth_interaction_params": { "urn:qauth:ia:email_code":
{ "purpose": "verify_address", "length": 6, "expires_in": 600, "resend_after": 60 } } }
→ auth_session=p0Lr…&qauth_interaction_type=urn%3Aqauth%3Aia%3Aemail_code&otp=482913&CA
← 200 { "authorization_code": "…", "iss": "https://auth.acme.example" }

The fallbacks and the token request follow. The token request carries no redirect_uri (FiPA §6 “Token Request”). Its response is ordinary. The ID token and the JWT access token carry auth_time and "amr": ["pwd"]. F3 and F4 add headers only (DPoP; OAuth-Client-Attestation).

HTTP/1.1 403 Forbidden
{ "error": "redirect_to_web", "iss": "https://auth.acme.example" }
HTTP/1.1 400 Bad Request
{ "error": "invalid_session", "iss": "https://auth.acme.example" }
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=uY29tL2F1dGhlbnRpY2F0aW9u
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&CA

Why. FiPA §1.2 leaves the steps to deployments; FiPA §5.1 and §5.2.2 “Error Response” allow additional parameters and members. On WG issue #133 an editor wrote that the draft’s otp_required example “should have been an additional response parameter rather than a different error value”; on WG issue #177 the editors held that “4xx are the right codes”. OpenID4VCI 1.1 “Interactive Authorization” already answers WG issue #173’s negotiation question; QAuth borrows its two member names, and if the WG settles these issues differently it emits the core equivalent beside its own members.

4. Step order — a uniform first response, a downgrade rule, and step-up as a fresh transaction

One engine decides every step in a fixed order, and before a factor succeeds every response is the same whatever the account’s state. A transaction completes only if it proved the account’s strongest bound factor. Step-up is a fresh transaction. acr_values is refused with the unmet_authentication_requirements error that RFC 9470 §5 names.

The engine is shared with the Interaction API that ADR-019 defines. The step grammar of Decision 3 has two transports: the Interaction API for browser ceremonies and this endpoint for native apps.

The engine evaluates these rules in order on every round. The first unmet rule produces the response.

  1. Transport: each transport’s own checks. At this endpoint they are the per-peer cap, the 16 KiB body, repeated parameters, schema bounds and the browser-origin guard (Decision 1).
  2. Client authentication, the first-partyness gate and the IP claim.
  3. Per-client limits; the auth_session lookup, binding and budgets; per-end-user-IP limits; the per-account ladder for credential rounds.
  4. Request checks: response_type, PKCE, acr_values, prompt, scope, negotiation, frozen parameters.
  5. A bot challenge, if a soft threshold requires one (F1b). If the client listed captcha, the captcha step comes next, and the pending step then resumes. Otherwise only the delay and 429 of step 3 apply (Decision 7).
  6. The identifier: login_hint, or a discoverable passkey from F2b.
  7. The first factor: passkey (F2b) before password.
  8. After a first factor only: the downgrade rule, the realm’s and client’s MFA policy, verification of an account that is not yet verified (for a password account, its address is unproved), the per-client post-password email check (FiPA endpoint only), TOTP (F2c), and from F2b an optional passkey enrolment offer.
  9. Mint the code.

Any other rule that needs a step the client did not list answers redirect_to_web.

  • Anti-enumeration. For a given client, request and configuration, every response before a factor succeeds is the same for an unknown, password-only, passkey-only, disabled or locked account. login_hint never changes a response before the first factor, and timing follows the same rule.
  • One subject per transaction. The first identifier given and the first user proven are fixed; every later factor is verified only against that user’s password, passkeys and address. A different login_hint, or an assertion or code of another user, ends the transaction with 400 invalid_session, audited as subject_mismatch.
  • Passkey-first (F2b). With no login_hint, the first response requires a discoverable webauthn_get and offers password and identifier; with one, it is the same password-required shape for every account, with webauthn_get offered. A passkey is always one tap away, and the response never says who holds one.
  • Downgrade rule (defined in F1 so the wire does not change; binding from F2b). A login completes only if the transaction proved the account’s strongest bound authenticator (passkey above password). On the hosted pages a recovery code may stand in for it (Decision 8); it adds no amr value. After a correct password on a passkey-bound account the next step is webauthn_get, or redirect_to_web if the client did not list it. An email code never replaces an authenticator. A disabled authenticator (Decision 7) still counts as bound, so disabling a factor never lowers what a login must prove. Wallet credentials are outside the ranking (ADR-009 §6 “Wallet presentation is onboarding and assurance-raising, not session authentication”).
  • MFA policy. ADR-020 sets MFA per realm and per client (off, optional, required, required for admins, where an admin is an account with an admin role in the realm). Step 8 enforces it on every transport. The engine reads it from the realm and client rows on every round and never caches it in the auth_session, so a change applies to live transactions. When the policy requires a second factor, a transaction completes only if its amr would carry mfa (Decision 10); the downgrade rule alone never satisfies it. An account with no usable second factor gets the hosted set-up step, never a code: the Interaction API offers it, and this endpoint, which has no TOTP enrolment step, answers redirect_to_web. A policy the engine cannot read refuses the round, as a store outage does (Decision 7).
  • Try another way (ADR-020). Before a factor succeeds, the list offers only what the uniform first response offers every account (on the hosted pages that includes a recovery code, for every account), so it reveals no account’s methods. After the first factor, it lists that account’s remaining methods that can still meet the downgrade rule and the MFA policy, and, on the hosted pages only, a recovery code (Decision 8). Email is never on it as a stand-in for an authenticator.
  • Eager answers. An initial request may carry the answer to the step it expects. The AS verifies it only if that step is the one it would ask for next; otherwise it ignores the value, with no verification and no failure counted. This keeps the happy path at two calls: one to the authorization challenge endpoint and one to the token endpoint. It also gives no password oracle when a bot challenge is due.
  • Step-up. For resource-server step-up (RFC 9470 §3 “Authentication Requirements Challenge”; FiPA §7 “Resource Server Error Response”), the client starts a new transaction. It sends the returned max_age and scope and a new PKCE pair. There is no post-code auth_session before F5. AS-initiated step-up at the token endpoint (FiPA §6.2 “Token Endpoint Error Response”) is F5. acr_values answers 400 unmet_authentication_requirements: RFC 9470 §5 says an AS “SHOULD consider the requested acr value as necessary” and that the error “will help prevent clients getting stuck in a loop”. No factor here produces acr (ADR-010 §2 “'low' emits no acr claim”), and acr_values_supported is not published (RFC 9470 §7 “Authorization Server Metadata”).
  • Dangerous scopes. isDangerousScope (helpers/step-up.ts) classifies write:*, agent:admin and agent:exec, and, once ADR-016’s pass-through leg lands, its <kind>:owner-token (ADR-016 §4). A first-party client’s administrative consent set is its allowlisted requested scopes minus dangerous ones, passed to evaluateStepUp as its prior-consent scopes at both endpoints. A dangerous scope answers redirect_to_web in every environment, so the browser’s consent screen and fresh login apply.

redirect_to_web answers a step the client did not list (except a bot challenge), a missing negotiation, a dangerous scope, prompt=consent, a passkey-bound account whose client cannot do passkeys, reset or registration under max_age or prompt=login, and a risk decision (FiPA §9.2 “Phishing”: the AS “MAY decide to require that the user go through a redirect-based flow at any stage”). In F1 no automatic rule makes a risk decision, so no step in the order above produces this trigger. A risk rule added later is named in the phase that adds it and has its own audit reason. redirect_to_web never answers a throttle: a throttle is 429, so an attacker is not moved to a path with other controls.

Why. FiPA §9.2 and §9.3 “Credential Stuffing Attacks”; RFC 9470 §4 “Authorization Request” and §5. An email code never replaces an authenticator because email is not one: NIST SP 800-63B-4 §3.1.3.1 “Out-of-Band Authenticators”, as in Four readings this record corrects. NIST SP 800-63B-4 §4.2 “Account Recovery” adds a second point. Replacing a forgotten password when the subscriber “can authenticate with one or more other authenticators” is “the binding of a new authenticator”, not account recovery.

5. Email codes verify and recover; they are never a sign-in factor

An emailed code proves control of a mailbox and is never a sign-in factor. It is used to verify a password account’s address, for recovery and for an optional per-client check after the password. It never adds an amr value, never counts toward mfa, never raises acr and carries no NIST AAL claim. Passwordless email-code sign-in is not offered.

The maintainer decided this on 2026-10-09 (question 2). The per-client check after the password exists only at the FiPA endpoint, which stays experimental. The Interaction API never offers it.

The code has a narrow job. A password account’s identifier is its address (ADR-002), so for that account the mailbox proof is the proof of its primary identity. That makes the code one way to verify an account. It is not the definition of a verified account. An account is verified when its primary identity is proved securely, of whatever type; ADR-015 decision 8 lists the proof for each type. An email address is an attribute, and an account may have none. An account whose primary identity is a passkey or a wallet has no address to prove and never meets this step.

One exception is stated openly: a headless reset of a password-only account ends in a code after a mailbox proof alone. That is an email-recovery sign-in at mailbox assurance (Decision 8); it carries no amr or auth_time, and it cannot satisfy max_age or prompt=login.

  • Codes come from a CSPRNG, are stored hashed in the auth_session state and are compared in constant time. They have 6 digits after a password and 8 digits for recovery and registration, where the code alone creates a verified binding or unlocks a reset. They are valid 10 minutes, single use, bound to the auth_session that asked for them, and burned after 5 wrong answers. A resend never resets a failure counter.
  • An account-level counter of wrong codes applies per normalised identifier (a keyed HMAC), across transactions, for existing and unknown addresses alike. From 5 consecutive wrong codes the soft ladder of Decision 7 applies, with a bot challenge from F1b. It has no hard stop. It never suppresses mail, and a correct code answered outside a wait is accepted. So this counter cannot close the reset that Decision 7’s disable relies on, and it cannot block a sign-up.
  • Send limits count every request that would send if the account existed, unknown addresses included. Only the tiers that a third party cannot spend for the owner are quiet.
    • Per address and end-user IP (3 per 15 minutes and 10 per 24 hours): at the cap the response is the same step, and the mail is quietly suppressed. Mail is dispatched after the response is built and every identifier-resolving round meets the timing floor, so “sent”, “not sent” and “capped” look alike.
    • Per address and FiPA client (10 per 24 hours): above it every address, existing or not, gets the same 429 temporarily_unavailable with Retry-After. The hosted pages have no per-client tier.
    • Per address, a send ceiling of 30 per 24 hours. Above it every address, existing or not, gets the same 429 temporarily_unavailable with Retry-After, so the answer reveals nothing about the account. The operator gets an alert, aggregated per address HMAC. An existing account, and on a registration start an address with no account, gets at most one notice per 24 hours, outside the caps; registration already mails addresses with no account. The notice says that codes were requested for the address and carries a link with a single-use 256-bit pass, bound to that address and valid 24 hours (opening the link never spends it; only the start it carries does), that takes one reset or registration start on the hosted pages past every per-address tier. The pass proves only that its holder reads the mailbox; the mailed code is still required. The send ceiling is never quiet: a quiet send ceiling would let a third party drop the owner’s own reset and verification mail.
    • Per end-user IP (10 per hour) and per FiPA client: these do not depend on the address, so they also answer 429 with Retry-After. The hosted reset and verification pages have no per-client send cap.
    • A request whose end-user IP is a known IP of a verified account (Decision 7) is exempt from every per-address tier. A code mailed after a correct first factor in the same transaction is exempt from the per-address send ceiling and counts toward its own per-address cap of 30 per 24 hours, which answers the same 429. The per-IP and per-FiPA-client caps still apply to both.
    • Mail that carries no code does not count toward these caps and is never suppressed by them. The disable and send-ceiling notices go out at most once per account per 24 hours, and the disable notice carries the same pass.
  • New templates: the code (verify, confirm, recovery), “an account already exists”, “your password was changed”, “your password was disabled after too many attempts” and the send-ceiling notice; from F2b and F2c also “a second factor was disabled”, “a wrong second factor was entered after a correct password”, “a recovery code was used” and “your recovery codes were regenerated”. As of 2026-09-26 createEmailService has one method, sendVerificationEmail.

Why. NIST SP 800-63B-4 §3.1.3.1, as in Four readings this record corrects; §3.2.5 “Phishing Resistance”: manually entered outputs “SHALL NOT be considered phishing-resistant”. §4.2.1.2 “Issued Recovery Codes”: the code “SHALL include at least six decimal digits”, lives at most “24 hours when sent to an email address”, and its verification “SHALL be subject to the throttling requirements in Sec. 3.2.2”. Each code is disabled after 5 wrong answers, well inside §3.2.2’s limit of 100. Across codes, the per-identifier counter uses the techniques §3.2.2 says “MAY be used to reduce the likelihood that an attacker will lock the legitimate claimant out due to rate limiting”: increasing waits and a bot challenge. A stop there would let an attacker close the reset that a disabled password needs. §3.1.3.2 “Out-of-Band Verifiers” (a new secret does not reset the failure count) governs authenticators, so it applies here by analogy only. RFC 8176 §2 “Authentication Method Reference Values” has no value for an emailed code.

6. auth_session — hashed, single-use per round, bound to client authentication, dead at the code

The auth_session is a 256-bit handle, stored only as its hash, consumed atomically and rotated on every response, bound on every round to the authenticated client, and ended when the code is issued. It is valid at most 10 minutes. From F3 it is also bound to the DPoP key, and from F4 to the attested instance key. A burned handle of a live transaction, presented by its own client, ends that transaction and is audited as a replay.

  • Value and storage: 32 CSPRNG bytes, base64url (43 characters), kept in Redis in the pattern of helpers/pending-authorization.ts, keyed by the handle’s hash, with a burn marker until the absolute expiry and a per-user index of live transactions so a reset can end them all.
  • Consumption: each round takes the state atomically (GETDEL, which F0 adds to the cache utilities) and binds a new handle, burn first, then bind (ADR-013 §5 “The return route burns first, then binds”). Of two concurrent answers with one handle, only the first to take the state is processed. The other presents a burned handle and follows the next bullet. While the transaction is live, that is a replay: it ends the transaction, and a round still in flight then binds no new handle and issues no code. Once the first answer has issued the code, the other gets invalid_session with no replay alarm. The SDK sends one round at a time per sessionKey, so a double submit from an SDK client never reaches this path.
  • A burned handle: the client binding is checked first, so a handle from another client gets invalid_session (audited client_mismatch) and never ends the owner’s transaction. From the owning client, while the transaction is live, the state is deleted and the replay audited. After completion or expiry the answer is invalid_session with no replay alarm, so a client that keeps its last handle, as FiPA §5.3.1 “Auth Session” requires, pays one round trip. The SDK never retries a round; after a network failure it starts a new transaction that keeps the user’s visible state.
  • Binding: the authenticated client must equal the recorded client on every round. From F3 a session with a DPoP key needs a valid proof for it on every round (FiPA §9.6.1 “Auth Session DPoP Binding”); from F4 it is bound to the attested instance key. An end-user IP change within a transaction is a risk signal, not a failure. It is audited and never ends the transaction. In F1 it changes no response. From F1b it counts as a soft threshold for the bot challenge (step 5 of the order in Decision 4).
  • Lifetime and budgets: FIRST_PARTY_AUTH_SESSION_TTL (new), default 600 s, maximum 900 s, absolute from round 0; at most 3 failed answers per step (5 per email code) and 10 rounds; the 200 and the token response carry no auth_session.
  • Carriage: form body only, never in a URL, a header, a log line or an audit row; LOG_REDACT_PATHS gains auth_session, password, new_password, otp, captcha_token and webauthn_response. The backend keeps one transaction per app-side session, never shared across end users and never sent to the device (SDK-enforced): client binding stops other clients, and this rule stops other users of the same backend.

Why. FiPA §5.3.1: the value is opaque, “unique to the session”, “SHOULD have a minimum of 256 bits of entropy”, and “SHOULD be bound to the device” (for a backend, the presenting device is the backend). FiPA §9.6 “Auth Session” allows other binding methods; §9.6.2 “Auth Session Lifetime” leaves the lifetime to the AS; FiPA §9.3 asks implementors to rate-limit per auth_session. RFC 9449 §5 “DPoP Access Token Request”: refresh tokens of confidential clients “are not bound to the DPoP proof public key because they are already sender-constrained”.

7. Abuse controls live at QAuth

QAuth, not the app, enforces every abuse control. A layered limiter is keyed on TCP peer, authenticated client, auth_session, end-user IP and account. A NIST SP 800-63B-4 §3.2.2 ladder counts failures across every password surface, and another counts them per second-factor type across every surface of that factor. Every counter that gates verification is reserved before the verifier runs. The end-user IP travels as a claim inside the backend’s signed, single-use client assertion. The limiter and ladder stores fail closed. Every credential round runs a dummy Argon2id verify and meets a timing floor. Input bounds are checked before any expensive work.

The controls belong to the engine, not to this endpoint’s switch. FIRST_PARTY_LOGIN_ENABLED gates only the FiPA route, its discovery members and the backend-peer cap below. The ladders and their hard actions, the send caps, the timing floor, the fail-closed stores and the unclaimed-account rule (Decision 8) apply to every transport that reaches the engine, the Interaction API and the hosted pages included, whatever the switch says.

The end-user IP. The qauth_end_user_ip claim is required for native_backend: an IPv4 or IPv6 literal of at most 45 characters. The maintainer chose this carrier on 2026-10-09 (question 4). The claim belongs to the FiPA endpoint only. QAuth canonicalises it, and turns an IPv4-mapped IPv6 address into IPv4, before any check or key. Outside development it refuses private, loopback, link-local, unique-local, shared (100.64.0.0/10), unspecified and multicast addresses. No IETF standard carries this value, so the claim is QAuth-defined. The signature proves who asserted the value and binds it to one request; it does not prove the value is right. RFC 9700 §4.13 “TLS Terminating Reverse Proxies” puts that duty on the component that receives the original request: “A reverse proxy MUST therefore sanitize any inbound requests to ensure the authenticity and integrity of all header values relevant for the security of the application servers”. That is the vendor’s ingress, so QAuth makes it a profile requirement: the backend derives the value from its peer address or a proxy chain it operates and lists, as QAuth’s own TRUST_PROXY does, and the SDK derives it from typed inputs. The SDK canonicalises each address the same way. It takes the peer address unless that address is in trustedProxies. Otherwise it reads the forwarded chain from the right and takes the first address not in trustedProxies, the same walk Fastify’s trustProxy makes for TRUST_PROXY. QAuth never reads X-Forwarded-For for this, TRUST_PROXY is unchanged, and audit rows record ip_source: asserted. The TCP peer and client_id keep their own caps, so a lying backend evades only the per-IP limits.

Limits (configurable; development may relax them per ADR-008 §5 “The profiles”; staging keeps production values):

DimensionKeySoft actionHard action
TCP peerrequest.ip—a per-route @fastify/rate-limit cap, tiered by resolveRealmRateLimitMax; a listed backend address gets FIRST_PARTY_BACKEND_PEER_RATE_LIMIT instead (see “Backend volume” below)
Clientauthenticated client_idany of: a failure ratio above 50 % over at least 200 credential rounds in 5 min, where a success counts at most once per account; or more failed credential rounds in 5 min than a tenth of what FIRST_PARTY_CLIENT_MAX_ROUNDS admits in that time (300 at the default), whatever the ratio: delay (bot challenge from F1b) and an alertabove FIRST_PARTY_CLIENT_MAX_ROUNDS (new, 600) rounds per minute: 429
auth_sessiontransaction—3 failures per step (5 per email code), 10 rounds: invalid_session
End-user IPthe claim; IPv6 keyed on /64; for the identifier count, its /24 (IPv4) or /48 (IPv6) bucket10 failed credential rounds in 15 min, or failed rounds for more than 20 distinct identifiers in 15 min from the bucket: delay (bot challenge from F1b); the identifier count also alerts30: 429 for 15 min
Account (password)HMAC(server key, realm, normalised identifier), whether or not the account exists; one counter for every password surfacefrom 5 consecutive failures, the next attempt waits 2^(n−5) s, capped at 15 min (bot challenge from F1b); an early attempt gets 429 and is neither verified nor counted100 consecutive counted failures: the password is disabled until a reset, and the user is notified
Account (email codes)the same key, a separate counterthe same ladder from 5 wrong codes (bot challenge from F1b)none: it never suppresses mail or refuses a correct code outside a wait (Decision 5)
Account (second factor)(realm, user, authenticator type), or the identifier HMAC while no user is resolved: one counter each for TOTP and for recovery codes, shared by every transaction, client, transport and IPthe same ladder from 5 consecutive failures (bot challenge from F1b), with no known-IP exemption; an early attempt gets 429 and is neither verified nor counted; a correct first factor never clears it; a rejected answer after a correct password notifies the user, at most once per 24 hoursTOTP: 100 consecutive counted failures disable it and notify the user, armed only while the hosted pages are on and the account keeps an unused recovery code, or while it keeps another usable second factor. Recovery codes: never disabled (at least 64 bits each); at 100 the user and the operator are notified
Registration startsend-user IP; client—3 per hour per IP (the REGISTRATION_RATE_LIMIT default); a per-client cap
  • Credential rounds. A credential round carries a password, an otp of any step, a recovery code or a webauthn_response. A rejected one is a failed credential round in the client and end-user IP rows, whatever the step, so a wrong TOTP or email code counts there as a wrong password does. A success counts toward the client’s ratio at most once per account in each window, so a campaign cannot dilute the ratio with sign-ins to a few accounts it owns. The client’s failure budget is derived from FIRST_PARTY_CLIENT_MAX_ROUNDS, so an operator who raises the cap for real traffic raises the budget and its alert with it.
  • Backend volume. Every user of a native_backend client reaches QAuth from the backend’s addresses. As of 2026-09-26, /oauth/token allows TOKEN_RATE_LIMIT (30) requests per TOKEN_RATE_WINDOW (60 s) per TCP peer in the strict tier. /oauth/revoke allows INTROSPECT_RATE_LIMIT (30) per INTROSPECT_RATE_WINDOW (60 s). A route with no cap of its own gets RATE_LIMIT_MAX (100) per RATE_LIMIT_WINDOW (3600 s). The design therefore requires an operator-set list of the flagged backends’ egress addresses, FIRST_PARTY_BACKEND_PEERS (new; an address or CIDR list, as TRUST_PROXY takes; empty by default). A listed address gets its own per-peer cap, FIRST_PARTY_BACKEND_PEER_RATE_LIMIT (new; requests per minute, set together with the list), at the authorization challenge endpoint, /oauth/token and /oauth/revoke. The list changes only that cap. QAuth reads no forwarded header for it, and the client, auth_session, end-user IP and account rows still apply. It takes effect only while FIRST_PARTY_LOGIN_ENABLED is on.
  • Known-IP exemption: an end-user IP with a successful sign-in to the account in the last 30 days skips the wait of the password and email-code ladders but still counts toward the disable ceiling. A wrongly asserted IP, or one shared through a carrier NAT, that matches a known IP skips the wait too. It never skips a second-factor wait: only a holder of the first factor reaches that ladder.
  • One counter across surfaces, whatever FIRST_PARTY_LOGIN_ENABLED says: from the first release that ships the engine, this endpoint, the Interaction API (and the hosted pages on it), /ui/login while it exists and /auth/login until F1c all increment the engine’s per-account counter, and a success on any clears it. A correct answer clears only the counter of the authenticator it proved, so a correct password never clears a second-factor counter. Every surface reserves before it verifies (next bullet); an attempt inside a wait is refused with that surface’s existing lockout answer, unverified and uncounted. The new endpoint writes nothing under the identifiers of helpers/failed-login.ts, so an asserted IP never sets or clears a lock on another surface; the legacy routes keep their own lock as well.
  • Reserve before verify. Every counter that gates verification is reserved atomically before the verifier runs. One atomic store operation reads the wait, refuses the attempt if it falls inside the wait, and otherwise counts the attempt as a failure and sets the next wait. A correct answer then settles the reservation: it clears the account counter as above, and counters of failures alone take the attempt back. So parallel attempts against one account, each in its own transaction, meet the same ladder and disable ceiling as attempts sent one at a time. The client, end-user IP and send-cap counters reserve the same way. A hard action fires only when a reserved attempt settles as a failure.
  • The disabled mark is a typed, authenticator-generic column, user_credentials.disabled_at. While the hard action is armed, the repository’s credential lookup returns a disabled credential as unusable, so no verifier can miss it. Every surface then refuses it with its ordinary invalid-credentials answer. A disabled credential still counts as bound for the downgrade rule and the MFA policy (Decision 4).
  • The hard action is armed only while every account it can reach has a self-service rebind path that a third party cannot exhaust. Web-only users have one only through F2a’s hosted reset, so the rule is checked at run time, not per release. While the hosted reset is off, no password is disabled, and the lookup ignores any mark set earlier. Every surface keeps its other throttling. A successful sign-in clears an ignored mark, as a reset does. The maintainer kept this rule on 2026-10-09 (question 7). The hosted reset is on whenever hosted pages are on (question 6), so the hard action is armed by default. F2a ships in the F1 release train. The send rules of Decision 5 keep that path open: no tier that a third party can spend for the owner is quiet, a known IP of a verified account never meets the per-address tiers, and the disable and send-ceiling notices carry a pass past the per-address tiers. A deployment that cannot send those notices, for example one with no mail sender, has no such path, and the hard action stays unarmed there. A realm cannot turn off the send-ceiling and disable notices, or the notice of a wrong second factor after a correct password (ADR-020 “Emails”). TOTP is disabled only while the hosted pages are on and the account keeps an unused recovery code, or while it keeps another usable second factor. So the disable is not a permanent lockout in the sense of ADR-020: it ends at the owner’s next reset or recovery code, which no third party can block.
  • Fail closed: on every transport of the engine, and on every legacy password surface once the engine’s counter exists, an error from any counter, session or bot-challenge store refuses the round before any credential is checked: 503 temporarily_unavailable at this endpoint, and each surface’s own unavailable answer elsewhere.
  • Timing: an unknown identifier runs one Argon2id verify against a fixed dummy hash, and every round that resolves an identifier or carries a credential (sign-in, reset start, registration start, code rounds) returns no earlier than MIN_RESPONSE_TIME_MS.LOGIN (500 ms), through ensureMinimumResponseTime, success or failure.
  • Input bounds, in the route schema before any handler work: a 16 KiB body (64 KiB only for a webauthn_create answer); password and new_password at 1–256 UTF-16 code units (PASSWORD_MAX_LENGTH) on the raw input, before normalisation, strength estimation or hashing, with zxcvbn seeing at most 100 characters (ZXCVBN_MAX_INPUT_LENGTH); login_hint 1–254 bytes; otp exactly length digits; auth_session exactly 43 characters; captcha_token 4096 bytes; webauthn_response 16 KiB; client_assertion 8192 characters (ASSERTION_MAX_LENGTH); the other parameters as at /oauth/authorize.
  • Bot challenge (F1b): a provider-neutral verifier. Its providers were decided on 2026-10-11 (maintainer).
    • The default provider is a proof-of-work challenge built into QAuth. The engine issues the puzzle in the captcha step and checks the answer itself, so it makes no outbound call. The reference ceremony app ships the solver as its own script, served from the realm origin, so the strict CSP of ADR-019 Decision 5 needs no exception for it.
    • The captcha step’s parameters take one of two forms, never both. The built-in proof of work sends provider pow, a name QAuth reserves, with the puzzle as challenge and difficulty. A hosted provider sends the name the operator gave it, with its site_key. Either way the answer comes back as captcha_token.
    • An operator may add their own hosted provider. QAuth checks its token through a new SSRF-safe POST helper built like ssrfSafeGet (https only, no redirects, DNS pinning, time and size caps) to one operator-configured host. On the hosted pages, its script may run only on the sign-up and sign-in pages, from script hosts the operator names; that list is empty by default (ADR-019 Decision 5). On every other hosted page, a required challenge is the built-in proof of work.
    • By choosing a hosted provider, the operator accepts that its script runs on the sign-up and sign-in pages, where passwords are typed. Like the ceremony app, that script then sees what the user types there and can act as the user on the realm origin.
    • A required challenge needs JavaScript on the hosted pages, whichever provider answers it. A user without JavaScript cannot complete a step that requires one there; the challenge is not skipped for them.
    • A required challenge with no provider, or an unreachable provider, refuses the gated action; it is never waved through and never replaced by redirect_to_web. A client that did not list captcha still gets the soft action’s delay and the hard action’s answer, a 429. It is not exempt, and it is not sent to redirect_to_web.

Why. FiPA §9.3 “Credential Stuffing Attacks”: “Implementors SHOULD consider similar security measures to reduce this risk in the authorization challenge endpoint.” NIST SP 800-63B-4 §3.2.2 “Rate Limiting (Throttling)”: failures are limited “to no more than 100 by disabling that authenticator”, which “is an upper bound”; disabled authenticators “SHALL be required to rebind to the subscriber account”; on success the verifier “SHOULD disregard any previous failed attempts”; and the claimant’s IP address is a listed risk signal. The limit is per authenticator, so each second factor has its own counter, and a correct first factor, a different authenticator, never clears it. With ADR-020’s TOTP parameters, three codes are valid at any instant, so a holder of the password gets at most 100 consecutive guesses, about 0.03 %, over at least 21.5 hours of waits, however many transactions, clients or IPs they use; each successful sign-in by the owner starts a new run. A rejected second factor after a correct password sends the user a notice, at most one per 24 hours, so the owner learns that the password is known. RFC 6749 §4.3.2 “Access Token Request” asks a password-grant AS to protect its endpoint against brute force, which applies here by analogy.

8. Registration, verification and reset in the same flow; one password policy

Registration, the verification of a password account’s address and password reset run inside the authorization challenge endpoint as operator-enabled flows that end in an authorization code. No account exists until the mailbox is proven and the password passes policy. Every response is identical whether or not the address exists. A reset never unlocks an account whose strongest authenticator is stronger than email. One password policy module sets every password QAuth stores.

These flows create and prove password accounts, whose primary identity is an address. They do not define a verified account (Decision 5). F1 registers password accounts only. F2b adds accounts whose primary identity is a passkey, with no address. They are in 1.0. Their registration ceremony proves them. Recovery codes are mandatory for them (question 30, decided 2026-10-09).

Registration. From F1 the signal is the standard prompt=create of Initiating User Registration via OpenID Connect 1.0, an OIDF specification. There is no path marker (question 14, decided 2026-10-09).

  • Registration needs both the realm’s registration_allowed and the client’s flows containing register. This holds for native sign-up here and for hosted sign-up (F2a), which extends the per-client list to the redirect flow (question 13, decided 2026-10-09). As of 2026-10-09 the column defaults to false, and nothing reads or writes it.

  • Discovery publishes prompt_values_supported from F1’s release. It lists every prompt value QAuth supports: create and the values /oauth/authorize accepts (none, login and consent as of 2026-10-09). §4.2 “Discovery Metadata” says a provider that supports the specification “MUST define this metadata element in the openid-configuration file”. It also says the provider “must also specify all other prompt values which it supports”.

  • /oauth/authorize honours create through the hosted pages of F2a. F2a ships in the F1 release train, so both endpoints honour it by F1’s release.

  • With prompt=create, openid is required: the flow “MUST NOT be considered successful without the return of a valid id_token” (§4.1 “Authorization Request”).

  • The first answer is always 403 email_code (verify_address, 8 digits), identical for new and existing addresses; an existing address receives a code with an “account exists” notice. After the code, a new address gets new_password (and, from F2b, an offered webauthn_create). An existing verified account is helped to sign in (password, webauthn_get from F2b, or redirect_to_web without a password), as the OIDF text allows: “Whether the AS creates a brand new identity or helps the user authenticate an identity they already have is out of scope for this specification”. An unclaimed account (defined below) follows the unclaimed-account rule.

  • Creation happens in one transaction after the policy passes: users, the verified password user_credentials row with the NFC marker, and the verified email user_attributes row. ADR-002’s tables and claim rules are unchanged; only the order differs from ADR-002 “Phase 1 Flow Mapping”. A unique-constraint race answers invalid_session. The code carries no amr or auth_time (Decision 10). FiPA Appendix A.8 “Registration” (non-normative) also runs registration inside the flow.

  • Unclaimed accounts. An account is unclaimed when it holds a password credential and is not yet verified (its address is unproved). An account that a wallet presentation created is never unclaimed.

    • Ending an unclaimed account. The first successful mailbox proof (registration or reset) on an unclaimed account ends that account and gives the mailbox owner a new one, with a new sub. This is a new identity, not a match on the address: the new account is never linked to the old one.
    • What is deleted. One transaction deletes the unclaimed user with every row keyed to it: credentials of every provider type (the password, any wallet binding or upstream link, any passkey from F2b, any TOTP secret from F2c), recovery codes, oauth_consents rows, refresh families, browser sessions, every live auth_session, and the API keys and clients it owns as a developer. So nothing of the unclaimed account carries over.
    • Audit and logout. Audit rows keep the old id: because audit_logs.user_id is set null when its user is deleted, F1 keeps the id of an ended unclaimed account outside that foreign key, and the end event records the old and the new id. QAuth sends a back-channel logout for the old sub to every app with a session, and state that a downstream app keeps under the old sub never reaches the mailbox owner.
    • Then. The transaction continues as the registration of a new address: new_password comes next, and creation follows the rule above. The registration gate does not apply here, because the new account replaces one that existed. No other authenticator is asked for, so step 3 of the reset finds none.
    • Other paths. From F2b and F2c, the hosted enrolment and management page refuses an unclaimed account, so it cannot bind a passkey or TOTP. The post-password verification step keeps every credential, because there the same person proved both the password and the mailbox.
  • The browser-session epoch is a per-user reset counter. It follows ADR-019’s session storage: Postgres is the source of truth, and Redis is a cache in front of it. resolveBrowserSession (helpers/browser-session.ts) treats a session created before it as absent and clears its cookie. A reset advances it, at this endpoint or on the hosted pages (F2a). Ending an unclaimed account revokes that user’s sessions outright. It is not a users column, because ADR-002 keeps users a pure identity anchor. As of 2026-10-09 browser sessions live only in Redis, and the sessions table is unused.

  • “Sign out everywhere” ends the user’s sessions directly (question 9, decided 2026-10-09). It revokes the user’s session rows in Postgres and clears their Redis cache. It sends a back-channel logout to every app with a session. It also revokes every refresh family of the user, as /auth/logout does today. The portal and the hosted account page (F2a) both offer it.

  • Verification. At this endpoint, an account that is not yet verified (for a password account, its address is unproved) is always verified after a correct password. This holds whatever REQUIRE_VERIFIED_ACCOUNT says. So no code from this endpoint belongs to an account that is not yet verified. This replaces /auth/verify for native clients.

  • REQUIRE_VERIFIED_ACCOUNT is today’s REQUIRE_EMAIL_VERIFIED (default false), renamed. The old name stays as a deprecated alias. A separate code change makes the rename.

Reset is a QAuth extension. FiPA Appendix A.2 “Redirect to Authorization Server” and §5.2.2.1.1 “Redirect to Web Error Response” route recovery to the browser instead.

  1. The client selects urn:qauth:ia:password_reset. The client’s flows must contain reset.
  2. QAuth answers 403 email_code (recovery, 8 digits) for every address. Mail goes only to a known address with a password.
  3. If the account has another bound authenticator (a passkey from F2b, TOTP from F2c), that authenticator is required next, or on the hosted pages a recovery code. A disabled one still counts, and email never stands in for it. If the client cannot render the step, or the user needs a recovery code, the answer is redirect_to_web.
  4. The new_password answer must pass the policy.
  5. One transaction replaces the hash through a new password-update method on the credential repository. It also marks the address verified and clears the password’s counters and its disabled mark, never a second factor’s. It revokes every refresh family, ends every live auth_session, advances the epoch and sends a notice. QAuth then answers 200 with a code that carries no amr or auth_time (Decision 10).

For a password-only account, the reset proves only the mailbox and then signs the user in. That is an email-recovery sign-in at mailbox assurance, with no amr, acr or auth_time (Decision 10). It is never offered under max_age or prompt=login, so it never satisfies a step-up.

Second factors and recovery codes (F2b, F2c):

  • TOTP uses ADR-020’s fixed parameters. Its secret is reversible, so it is stored encrypted under ADR-019’s data-at-rest key. Enrolment binds it only after a first valid code, and an unclaimed account cannot enrol. Each TOTP credential stores the last time step it accepted and refuses a code for that step or an earlier one, so an accepted value is never accepted twice (RFC 6238 §5.2 “Validation and Time-Step Size”).
  • A recovery-code set is 10 codes, each with at least 64 bits from a CSPRNG. Codes are shown once, stored only as salted hashes from the password hasher, compared in constant time and used once. A set is issued at the first enrolment of any second factor (a passkey from F2b, TOTP from F2c) and at the registration of a passkey-only account (question 30). Regenerating a set invalidates the old one and sends a notice.
  • A recovery code is redeemed only on the hosted pages; this endpoint answers redirect_to_web for that path (question 25). It stands in for a second factor after the first factor, or for the passkey of a passkey-only account. It adds no amr value and never mfa, so it never meets an MFA policy or a step-up that needs mfa. Where the policy requires a second factor, the user binds a new one before the transaction completes (Decision 4). Each use sends a notice, and Decision 7’s second-factor row throttles it.
  • Binding, removing or replacing an authenticator, and regenerating recovery codes, need a fresh authentication at most 5 minutes old that includes the account’s strongest bound factor. Only a rebind after a lost factor lets a recovery code stand in for that factor. Binding the first passkey or TOTP of a password-only account also needs a code mailed to its address in the same transaction, unless the transaction already proved that mailbox; the code adds no amr. Each change sends the notice ADR-020 requires.
  • A lost second factor is recovered only with a recovery code or through an admin (ADR-020), never by email alone. A reset never removes, skips or replaces a second factor.

On the wire, registration starts on an initial request with prompt=create. Reset starts with qauth_interaction_type set to its path marker. On both paths login_hint carries the address unless the transaction already has one. The registration request’s interaction_types_supported lists urn:qauth:ia:email_code and urn:qauth:ia:new_password, plus urn:qauth:ia:password so that an existing account can sign in. Reset starts on the initial request, or from any 403 that offers urn:qauth:ia:password_reset. Its initial interaction_types_supported lists urn:qauth:ia:password_reset, urn:qauth:ia:email_code and urn:qauth:ia:new_password, because the list is frozen at round 0. For a new address, or for a password-only account, the answers are 403 email_code (8 digits), then 403 new_password, then 200 with the code. A step the client did not list answers redirect_to_web (Decision 4).

Disabled users. These flows never change a user whose users.enabled is false, and never give that user a code. Registration and reset send that address no mail, as at a quiet send tier (Decision 5), so the step looks the same. Every answer for that user gets the ordinary invalid_credentials answer. This includes a code mailed before the user was disabled. No hash, mark, counter, refresh family or address state changes, and no notice is sent. Each round re-reads the flag before it writes anything or mints a code.

One password policy, in libs/shared/validation/src/lib/password.ts, used by this endpoint and, until F1c, by /auth/register. Any future admin or SCIM API creates users without a password or through this module; the F1d import tool only carries existing hashes. The raw length (at most 256 UTF-16 code units) is checked first, then NFC normalisation, then PASSWORD_MIN_LENGTH (new), which defaults to 15 code points. An operator may lower it to no fewer than 8. While any account can sign in with a password alone, a minimum below 15 is a knowing deviation from NIST SP 800-63B-4 §3.1.1.2 “Password Verifiers”. The blocklist is zxcvbn’s dictionaries plus context words passed as zxcvbn user inputs (the address’s local part and domain; service, realm and client names). A password is blocklisted only when the whole password matches an entry; the same section says “The entire password SHALL be subject to comparison, not substrings or words”. Separately, a zxcvbn score below PASSWORD_MIN_SCORE (default 2) rejects the password. This score floor is a QAuth rule beyond the NIST blocklist. It requires no mix of character types, so QAuth does not count it as a composition rule. A breached-password corpus is F5. There are no composition rules and no periodic change. Rejection reasons are returned (too_short, too_long, blocklisted, too_weak), and fields are marked for password managers. New and changed passwords are hashed after NFC normalisation, and the credential records a marker, declared as an optional field of passwordCredentialDataSchema. Every verifier honours it: verifyPasswordCredential (helpers/credential-auth.ts, which serves /auth/login, /ui/login and the new endpoint) and the direct verifyPassword call in routes/auth/verify.ts; a credential without the marker is verified on the raw input. Applying the minimum to /auth/register is an announced, listed F1 change on every deployment, with a release note and the portal form showing the new reasons. The maintainer decided this on 2026-10-09 (question 3). Existing passwords are kept.

Why. NIST SP 800-63B-4 §3.1.1.2 “Password Verifiers”: single-factor passwords must be “a minimum of 15 characters in length”; only passwords used solely within multi-factor processes may be shorter, and never below eight; the CSP “SHALL provide the reason for rejection”; verifiers “SHALL allow the use of password managers” and “SHALL NOT impose other composition rules”. §4.2 “Account Recovery”: “Replacement of a forgotten password where the subscriber can authenticate with one or more other authenticators is considered to be the binding of a new authenticator”. §4.2.3 “Account Recovery Notification”: recovery “SHALL cause a notification”. RFC 9700 §4.14.2 “Recommendations” allows revoking refresh tokens after a password change.

Consent may be skipped only for an operator-flagged first-party client, and only for non-dangerous scopes in its registered allowlist; the decision is still made and audited on every request, and every other path requires consent.

  • The waiver applies at the new endpoint for native_backend (and native_attested), and at /oauth/authorize for any client whose profile resolves, so the portal (web_redirect) needs no consent screen. canSkipConsent gains that one operator-set condition, through resolveFirstPartyProfile, and no other. No oauth_consents row is written; it would list apps the user cannot meaningfully revoke.
  • The waiver covers the administrative consent set of Decision 4. Dangerous scopes and prompt=consent always get the consent screen (redirect_to_web at the new endpoint). Every waiver is audited as oauth.consent.administrative (basis, client, scopes, path).
  • The waiver needs no switch: it is inert until an operator sets a profile, as with ADR-011 §7 “private_key_jwt (#384) — additive, no flag”. So the portal can move in F0 without enabling headless login. At /oauth/authorize, QAuth treats every request from a client whose profile is native_backend (or, from F4, native_attested) as carrying prompt=login, whatever the request says. A prompt=none request from such a client answers login_required (OpenID Connect Core 1.0 §3.1.2.6 “Authentication Error Response”). Before F5 the client builds the browser-leg URL, so QAuth does not rely on the parameter arriving intact. So the waiver never lets a native app’s browser leg complete without a visible login.
  • From F0, the headless hand-off on /oauth/authorize follows this decision. F1c removes it with /auth/login, with no freeze phase (question 19).

Why. FiPA says nothing about consent. OpenID Connect Core 1.0 §3.1.2.4 “Authorization Server Obtains End-User Consent/Authorization”: the AS “MUST obtain an authorization decision”, and may establish consent “via conditions for processing the request or other means (for example, via previous administrative consent)”.

10. Codes, tokens, amr and sender-constraint phasing

Challenge-issued codes carry no redirect_uri, live 60 s, stay PKCE-bound by default, and yield tokens with auth_time and RFC 8176 amr (neither after registration or reset). Each code is marked issued_via = 'authorization_challenge', and a CHECK enforces the missing redirect_uri. Each code is linked to the refresh family it creates, so a replay revokes that family. amr and auth_time stay fixed across refreshes, and no new acr is added. Access tokens stay Bearer until F3. This is a knowing deviation from FiPA §9.5’s SHOULD. From F3, DPoP is on by default for native_backend clients (question 24).

-- F0
authorization_codes.issued_via enum('authorize','authorization_challenge') NOT NULL DEFAULT 'authorize'
authorization_codes.redirect_uri text NULL -- was NOT NULL
CHECK ((issued_via = 'authorization_challenge') = (redirect_uri IS NULL))
authorization_codes.challenge_redirect_uri text NULL -- a redirect_uri sent on the challenge request
CHECK (challenge_redirect_uri IS NULL OR issued_via = 'authorization_challenge')
authorization_codes.amr jsonb NULL; refresh_tokens.auth_time bigint NULL; refresh_tokens.amr jsonb NULL
refresh_tokens.authorization_code_id uuid NULL → authorization_codes(id) ON DELETE SET NULL
-- F3: authorization_codes.dpop_jkt, refresh_tokens.cnf_jkt, oauth_clients.dpop_bound_access_tokens
  • One new helper, mintAuthorizationCode, replaces the two mint sites (createCode in routes/oauth/authorize.ts and the consent POST in routes/ui/consent.ts) and serves the new endpoint. It refuses to mint without an S256 challenge of 43–128 characters, in every environment. It has one exception: a challenge code for a client whose operator set pkce_required: false (Decision 2, question 11). So every browser code is PKCE-bound by construction. Without PKCE, a challenge code is bound only to the authenticated client. It never passes through a browser.
  • The token endpoint’s redirect_uri rule (optional in tokenExchangeAuthCodeBodySchema, applied in handleAuthorizationCode): a browser code needs it, equal to the stored value; a challenge code with a challenge_redirect_uri accepts it absent or identical; a challenge code without one accepts it absent. A browser code without it, or any code with a different value, is invalid_grant. RFC 6749 §5.2 “Error Response” gives that code to a grant that “does not match the redirection URI used in the authorization request”. Whether the parameter is required depends on the code, so a missing value counts as a mismatch, not as a missing parameter (invalid_request).
  • Challenge codes live 60 s; browser codes keep AUTHORIZATION_CODE_TTL_MS. A second redemption of any code is denied and revokes the family issued from it, audited as oauth.token.code_replay.
  • Refresh-family lifetime (question 23, decided 2026-10-09). When a realm’s sso_max_lifespan is set, it caps every refresh family in that realm. When it is unset, today’s sliding REFRESH_TOKEN_LIFESPAN is unchanged. As of 2026-10-09 the column exists on realms, is nullable, and nothing reads it.
  • amr and auth_time appear in the ID token and the JWT access token for challenge-issued codes, fixed across refreshes. Browser-flow tokens are unchanged in F1; amr on browser logins is F5.
  • A registration or reset code carries neither amr nor auth_time, and neither do the tokens it yields. RFC 9470 §6.1 “JWT Access Tokens” uses auth_time to tell a resource server when the user authenticated, and §3 “Authentication Requirements Challenge” measures max_age from that event. So a token from a transaction that proved only a mailbox never passes a resource server’s max_age check. OpenID Connect Core 1.0 §2 “ID Token” requires auth_time when max_age is requested, and these paths are never offered under max_age.
Transactionamr (RFC 8176 §2 and the IANA-registered pop)
Password (with or without the post-password email check)["pwd"]
Passkey with user verification (F2b)["pop","mfa"]
Password, then passkey (downgrade rule, F2b)["pwd","pop","mfa"]
Password and TOTP (F2c)["pwd","otp","mfa"]
Registration, resetamr and auth_time omitted

hwk and swk are never claimed: QAuth requests attestation: "none" and cannot know the key store. The server verifies the user verification (UV) flag in the signed authenticator data of every assertion and refuses an assertion without it, so ["pop","mfa"] means a possession factor whose authenticator reported a local verification, not an attested multi-factor authenticator. No factor here produces acr; an authenticator-strength acr is an ADR-010 amendment.

ClientAccess tokenRefresh tokenPhase
native_backend before F3Bearer, kept on the backend; the FiPA §9.5 “Sender-Constrained Tokens” SHOULD is not met, a knowing deviationbound to the client (the ownership check in handleRefreshToken), rotatedF1
native_backend from F3, DPoP on (the default)cnf.jkt, token_type: DPoPbound to the clientF3
native_backend from F3, DPoP switched off by the operatorBearer; a boot warning names the client, and the FiPA §9.5 deviation is recorded for itbound to the clientF3
native_attestedDPoP-boundbound to the client instance (attestation -11 §10.3 “Refresh token binding”)F4

From F3, DPoP is on by default for every native_backend client (question 24, decided 2026-10-09). It binds the access tokens, the code and the auth_session to the backend’s DPoP key. The seed manifest sets dpop_bound_access_tokens for such a client unless the operator switches it off. A switched-off client keeps Bearer access tokens. QAuth logs a warning at boot that names it, and the FiPA §9.5 deviation is recorded for that client. While DPOP_ENABLED is off, no client is DPoP-bound, and QAuth logs the same warning for every native_backend client.

Why. RFC 6749 §4.1.3 “Access Token Request” requires redirect_uri, identical, when the authorization request included it. OAuth 2.1 -16 §10.2 “Redirect URI Parameter in Token Request”: an AS serving both versions “MUST allow clients to send the redirect_uri parameter in the token request (Section 4.1.3), and MUST enforce the parameter as described in [RFC6749]”. FiPA §6 “Token Request”: the parameter “will not be included in this request, because no redirect_uri parameter was included in the authorization request”. RFC 6749 §4.1.3 ties its presence rule to a redirect_uri sent in a Section 4.1.1 authorization request, which is where a browser code comes from. A challenge code comes from the authorization challenge endpoint instead. For it QAuth applies the OAuth 2.1 rule, not RFC 6749’s presence rule. OAuth 2.1 -16 §10.2 says PKCE prevents code injection, which makes the parameter “serve no purpose in the token request”. So a value sent on the authorization challenge request may be omitted at the token endpoint. If it is repeated, it must be identical. OAuth 2.1 -16 §4.1.3 “Token Endpoint Extension”: for a replayed code the AS “MUST deny the request and SHOULD revoke (when possible)” its tokens. RFC 9068 §2.2.1 “Authentication Information Claims”: the values “are fixed and remain the same across all access tokens that derive from a given authorization response”.

11. Discovery and audit

Discovery advertises exactly what the endpoint accepts, and only while the switch is on; every accept and every reject writes an audit_logs row without secrets, with identifiers as keyed HMACs.

Discovery member (buildAuthorizationServerMetadata, helpers/discovery.ts)When
authorization_challenge_endpointFIRST_PARTY_LOGIN_ENABLED (FiPA §8 “Authorization Server Metadata”)
private_key_jwt in revocation_endpoint_auth_methods_supported and introspection_endpoint_auth_methods_supported, with revocation_endpoint_auth_signing_alg_values_supported and introspection_endpoint_auth_signing_alg_values_supported from the same constant as the token endpoint’s list (ASSERTION_SIGNING_ALG_VALUES_SUPPORTED)F0, when those endpoints accept it (RFC 8414 §2 “Authorization Server Metadata”: MUST when private_key_jwt is listed)
end_session_endpointF0, once the browser end-session route exists (OpenID Connect RP-Initiated Logout 1.0 §2.1 “OpenID Provider Discovery Metadata”)
backchannel_logout_supportedF0, once back-channel logout ships (OpenID Connect Back-Channel Logout 1.0 §2.1 “Indicating OP Support for Back-Channel Logout”)
amr in claims_supportedF1, switch on
prompt_values_supported listing every supported prompt value, create includedF1’s release, when both endpoints honour it (F2a ships in the same train)
dpop_signing_alg_values_supportedDPOP_ENABLED (RFC 9449 §5.1 “Authorization Server Metadata”); its algorithms apply at the token endpoint and, from F3, at the authorization challenge endpoint, which refuses DPoP until then
attest_jwt_client_auth_dpop in token_endpoint_auth_methods_supported; the attestation algorithm membersF4 (attestation -11 §8 “Authorization Server and Resource Server Metadata”)
acr_values_supportednot published; an ADR-010 amendment that makes both endpoints honour acr_values would add it (RFC 9470 §7 “Authorization Server Metadata”)
challenge_endpoint of attestation -11 §6.3 “Providing Challenges through the Challenge Endpoint”never: combined mode takes freshness from DPoP nonces, and the name collides

Audit goes through fastify.repositories.auditLogs.create under the rule of ADR-011 §8 “Audit”: never a password, code, otp, auth_session, assertion, key, WebAuthn signature or bot-challenge token. The identifier appears only as the throttle HMAC while no user is resolved, and userId is set only after a factor succeeds. ip_address holds the end-user IP, metadata.ip_source says where it came from, and metadata.peer_ip holds the TCP peer. The events cover client rejections by reason (oauth.authorize_challenge.client_rejected), every step and outcome (oauth.authorize_challenge.*, with session rejections by reason, including subject_mismatch, and throttling aggregated per key and window), administrative consent, code replay, profile changes (oauth.client.first_party_changed), browser sign-out, registration, verification, reset, ended unclaimed accounts (the old and the new user, with what was removed), epoch advances, disabled authenticators, send-ceiling alerts, recovery-code use and regeneration (F2b, F2c), notifications, passkey binding (F2b) and imports (F1d). A Prometheus counter, qauth_first_party_steps_total, sits beside them. The rows form the dated authenticator life-cycle record NIST SP 800-63B-4 §4.1 “Authenticator Binding” asks for.

Why. The house rule that discovery advertises only what an endpoint accepts (ADR-008 §3 “Fail-safe defaults (the safe method)”). RFC 8414 §2 “Authorization Server Metadata”: each …_auth_signing_alg_values_supported member “MUST be present” when private_key_jwt is listed for that endpoint. FiPA §4.1 “Authorization Challenge Endpoint”: servers “SHOULD include the URL of their authorization challenge endpoint in their authorization server metadata document”.

12. No password grant, not even for compatibility — and the migration path

QAuth offers no password grant in any form. An app that calls a password grant moves to one authorization challenge call plus one token call, with the same happy-path shape. An app leaving another IdP also needs its users imported, as a separate step. QAuth applies the rule to itself. Its portal moves to the redirect flow in F0. /auth/login and the headless hand-off are removed in F1c, after a supported replacement for scripted management tokens exists. That replacement is an operator-listed public CLI client, shipped before 1.0 (question 8, decided 2026-10-09).

Why no compatibility mode. RFC 9700 §2.4 says the grant “MUST NOT be used”, with no first-party exception; OAuth 2.1 -16 §10 “Differences from OAuth 2.0” omits it “as per Section 2.4 of [RFC9700]”; RFC 10017 §7.3 repeats the rule for browser apps. A compatibility grant has two options. If it runs the engine’s steps, it may answer that an email code is needed. Then it is no longer ROPC. If it skips them, verification, the ladder, the session budget and the downgrade rule no longer protect any account. It cannot be compatible and safe at once. It would also put password in grant_types_supported and invite third parties to use it. And it buys nothing: with eager answers, the happy path already takes credentials and returns a code.

One limit remains. FiPA fixes ROPC’s protocol problems: the AS signals each step, controls every factor and limits the flow to first-party apps. But a password step still shows the password to the app. RFC 9700 §2.4 objects that the grant exposes credentials to the client and trains users to type them outside the AS. Both objections still apply to a password step. FiPA §9.2 “Phishing” says the same. A passkey sign-in (F2b) avoids them, because the app never sees a password. They still apply whenever a user types a password.

Before, a password grant at another IdP; after, the SDK of Decision 13 (on the wire, the eager-password request and the token request of Decision 3):

POST /token Authorization: Basic <client_id:client_secret>
grant_type=password&username=alice%40example.com&password=…&scope=openid%20offline_access
// before: const tokens = await idp.passwordGrant({ username, password });
// once, at startup (discovers metadata and checks the issuer):
const qauth = await createFirstPartyClient({ issuer, clientId, signingKey, steps, store });
// per sign-in request:
const r = await qauth.signInWithPassword({
sessionKey: appSession.id,
identifier: username,
password,
scope,
endUser: {
peerAddress: req.socket.remoteAddress,
forwardedFor: req.headers['x-forwarded-for'],
trustedProxies: ['10.0.0.0/8'],
},
});
// r.kind is one of four values:
// - 'tokens': iss checked, PKCE redeemed.
// - 'step': step view JSON for the app. A wrong password is a 'step'
// whose error is 'invalid_credentials', never a 'failed'.
// - 'redirect_to_web': a URL for the system browser. The app returns through its
// redirect URI, and the backend calls qauth.redirectToWeb.complete with the same
// sessionKey (Decision 1, requirement 6).
// - 'failed': the reason is 'session_expired', 'throttled' (with retryAfter) or 'unavailable'.
Password grantQAuth first-party profile
grant_type=password at the token endpointresponse_type=code at the authorization challenge endpoint, then grant_type=authorization_code at /oauth/token
usernamelogin_hint, which must be an email address
passwordpassword, eager with qauth_interaction_type=urn:qauth:ia:password, or as a step answer
client_secretprivate_key_jwt (aud = issuer as sole value, typ: client-authentication+jwt, at most 60 s, qauth_end_user_ip)
scopescope. A dangerous scope (write:*, agent:admin, agent:exec and, once ADR-016’s pass-through leg lands, its <kind>:owner-token; Decision 4) is never granted headlessly. A request that includes one answers redirect_to_web, and the sign-in continues in the browser
400 invalid_grant for bad credentials403 insufficient_authorization, same step, qauth_interaction_error: "invalid_credentials"
—code_challenge (S256) and interaction_types_supported, filled by the SDK
Refresh; logoutthe same refresh with private_key_jwt; /oauth/revoke with private_key_jwt (F0)
User creation or reset through an admin APIthe register and reset flows

What an app with a backend changes, beyond the wire mapping:

  • The operator seeds the backend as native_backend with its JWKS, a written reason, its browser-leg redirect URI and the flows it needs (Decision 2). flows defaults to login, so an app that registers or resets users also lists register and reset.
  • The backend signs a fresh client assertion for every request, carrying the derived end-user IP (Decision 1, requirement 3).
  • The backend keeps one auth_session per app-side session in a server-side store (Decision 1, requirement 2). That app-side session exists before the user signs in.
  • The app renders email_code, new_password and redirect_to_web from StepView (Decision 13). For redirect_to_web it opens the system browser and returns through its registered redirect URI, app-claimed https on mobile (Decision 1, requirements 5 and 6).
  • Registration and reset move from /auth/register or any admin API into the register and reset flows.
  • Tokens stay on the backend, and logout revokes the refresh token at /oauth/revoke (Decision 1, requirement 8).
  • A user whose account is not yet verified (for a password account, its address is unproved) enters an email code after the first correct password (Decision 8).
  • Passkey screens come in F2b.

Two populations migrate. QAuth’s /oauth/token has never offered grant_type=password (the enum comment in libs/infra/db/src/lib/schema/enums.ts reads “OAuth 2.1 removed deprecated grant types (password, implicit)”). Apps on QAuth’s own /auth/login keep their users and their Argon2id hashes, so they need only the protocol change and the app changes above. Such an app whose front-end is a web app takes the redirect flow instead (table below). Apps on another IdP’s password grant are the main population. As of 2026-09-26 QAuth has no user import path (password users are created only by routes/auth/register.ts), so these apps need the protocol change and a user migration. The maintainer decided the migration on 2026-10-09 (question 5):

  • An operator import tool in F1d imports Argon2id PHC hashes only.
  • An imported account counts as verified only if the source proved its primary identity.
  • Users whose hashes cannot be imported sign in through 1.0’s upstream OIDC login (ADR-018). Their old provider is the upstream. That path is a redirect flow, not a password grant.

An imported password account needs an email address. The sign-in identifier is an email address: login_hint must have email syntax, and a password credential is keyed by the normalised address (ADR-002). An app whose users sign in with a user name or a phone number gives each user an email address before it moves.

Migrating appPath
Native app whose backend calls a password grant and keeps the tokensThe authorization challenge endpoint (above)
Native app whose backend calls a password grant and passes the tokens to the deviceThe authorization challenge endpoint, with the tokens kept on the backend (Decision 1, requirement 8). The device holds only its session with the backend. The backend calls the APIs itself, or forwards the app’s calls only to an allowlist of APIs (by analogy with RFC 10017 §6.1.3.6 “Proxy Restrictions”). Tokens held on the device arrive with attested apps (F4)
Native app whose device calls a password grant directly (a public client)The RFC 8252 browser flow, or add a backend; headless public clients arrive with attestation (F4)
Web app (SPA with a BFF, or server-rendered)The redirect flow at /oauth/authorize; hosted registration and reset in F2a
Scripts that obtain a developer token from /auth/loginThe operator-listed public CLI client of question 8: the code flow with PKCE and a loopback redirect. It ships before F1c and before 1.0

QAuth’s own surfaces move in these steps. In F0 the portal moves first: its SYSTEM_CLIENT_ID row becomes a private_key_jwt web_redirect client (assertManagementToken keeps working because it pins that row), and portal login moves to /oauth/authorize. The portal’s tokens move server-side (Phasing, F0). In F1c, /auth/login, /auth/logout and the hand-off are removed directly, with no freeze phase (question 19, decided 2026-10-09). Removal comes as soon as F0’s portal move and the CLI client of question 8 exist, and before 1.0. Until then, /auth/login refuses any account with a bound passkey, with its ordinary invalid-credentials answer. /auth/register, /auth/verify and /auth/resend-verification issue no tokens; they adopt the one password policy in F1. A legacy route is never removed while its replacement is off by default. So /auth/verify and /auth/resend-verification stay until their hosted replacements are on for the portal in a default deployment (F2a). /auth/register is the one exception (decided 2026-10-09). F1c removes it too, once F2a’s hosted sign-up page exists. That page replaces it. It is off by default (question 6). An operator who wants sign-up turns it on. While sign-up is on, the portal signs up through that page by redirect.

13. SDK scope — a server-side Node SDK and a language-neutral kit

The first client SDK is the server-side @qauth-labs/node (in libs/sdk/node), with a typed step union, a published step-view JSON Schema and message catalogue, and every safety check built in. F1 also publishes a language-neutral kit for other backends. Mobile SDKs come with attested public clients in F4. There is no browser SDK.

  • @qauth-labs/node (F1) depends only on jose. It targets Node 22 and later (Node 22 is the oldest Node release line still in support on 2026-09-26) and runtimes with WebCrypto and fetch. createFirstPartyClient discovers metadata, checks issuer, and refuses to start without authorization_challenge_endpoint. It offers signIn.start, signIn.continue, signInWithPassword (the eager path), register.start, reset.start, tokens.refresh(), signOut() (revoke, then drop state), and redirectToWeb.start and .complete, which refuses a state stored under another sessionKey and is called only over the app session that started the leg. These functions sit in the package’s first-party subpath. Like the endpoint, it stays experimental until FiPA is an RFC (ADR-018).
  • Results are values; only programmer or infrastructure faults throw. StepView is a discriminated union with one variant per step of the catalogue, carrying only what a screen needs (for example purpose, length and resendAfter for a code, or minLength, maxLength and reasons for a new password), plus alternatives and an optional error.
  • Built-in checks:
    • Assertions carry aud = the issuer as sole value, typ, iat, a fresh jti, a 60 s life and the IP claim.
    • endUser is a required typed field from which the SDK derives the IP claim. Forgetting the abuse control is a compile error, and a free-form string is not accepted.
    • PKCE.
    • Handle rotation goes through a store keyed one-to-one by sessionKey (a Redis adapter in F1). The last handle is kept until logout and is not presented for a new transaction before F5 (a knowing deviation from FiPA §5.3.1 “Auth Session”).
    • No automatic retry, and one round in flight per sessionKey.
    • Errors are classified by error, not by status.
    • iss is compared with the pinned issuer before redemption.
  • The AS sends no text. The SDK ships a StepView JSON Schema and an English message catalogue. In F1 a native app renders step-view JSON relayed by its backend. It needs its own screens for the steps and its own code for the browser leg, but no QAuth SDK in Dart, Swift or Kotlin. For backends in Java, Go, Python or .NET, F1 publishes a JSON Schema of the whole wire, request and response test vectors, and a raw-HTTP guide.
  • As of 2026-09-26 nothing in the workspace publishes an npm package. F1 adds npm publishing for @qauth-labs/node (the scope, an nx release configuration, a pre-1.0 versioning rule, provenance, a release workflow), and a package installable from npm is an F1 exit criterion. The README’s “Planned SDK Usage (Phase 3)” section shows a browser-side auth.signInWithPassword and a @qauth-labs/react package; after this record no browser-side signInWithPassword ships, and any @qauth-labs/react stays redirect-only.
  • Flutter and Dart come in F4, in a separate repository: attestation evidence to the vendor’s attester, DPoP combined mode with keys in platform secure storage, platform passkeys, redirect_to_web through ASWebAuthenticationSession or Custom Tabs, and the same StepView. Until then the F1 guide shows the app-side half of the browser leg as vendor code.

Why. FiPA §9.7.3 “Mitigation”: “it is RECOMMENDED that a client-side SDK be used” when several first-party apps exist. FiPA §9.7.1 “User Experience Risk”: “the implementation MUST ensure the native experience is identical across all the first-party applications.”

14. One DPoP verifier, one WebAuthn provider — fit with ADR-007 and ADR-014

ADR-017 adds a human-login product line beside ADR-007’s near-term identity without changing it. It shares one DPoP verifier and one WebAuthn CredentialProvider with ADR-014. The DPoP verifier is flag-neutral, under DPOP_ENABLED, which AUTHORITY_TREE_ENABLED requires. The WebAuthn provider sits under WEBAUTHN_ENABLED. This record pulls private_key_jwt at revocation and introspection forward into its F0. A first-party client never uses the MCP or agent door, and an MCP or agent client never uses the first-party door.

  • Positioning. ADR-007 “Decision” sets “The open-source, self-hostable OAuth 2.1 authorization server for MCP servers and AI agents” and does not exclude human login. This record feeds no agent path in any phase: a challenge code never roots an Authority Tree, MCP, DCR and CIMD clients are refused, and there is no SSO carry-over into the browser.
  • DPoP. ADR-014 “Phasing” plans DPoP at the token endpoint in P1a, behind AUTHORITY_TREE_ENABLED. The amendment (question 10, decided 2026-10-09): the verifier (RFC 9449 §4.3 “Checking DPoP Proofs”, a jti burn, nonces per §8 “Authorization Server-Provided Nonce” with §11.3 “DPoP Nonce Downgrade”, cnf.jkt) lands once in a flag-neutral library behind DPOP_ENABLED; AUTHORITY_TREE_ENABLED requires DPOP_ENABLED; the operator-set dpop_bound_access_tokens column is shared; whichever phase lands first builds it. private_key_jwt at /oauth/revoke and /oauth/introspect, also in P1a, needs no DPoP and is opt-in per client, so it moves to ADR-017 F0: a backend cannot log out without it.
  • WebAuthn. ADR-014 §14 “Remote approval — a refused request the owner approves out of band” makes a passkey the default factor, and its phase P5 needs “A WebAuthn credential provider — passkey registration and assertion in the portal”. F2b builds it once, as an ADR-003 CredentialProvider (provider_type of webauthn, external_sub = credential id), with step verifiers above the single-shot verify() for second-factor use, optional enrolment after sign-in, the NIST SP 800-63B-4 §4.1.2.1 “Binding an Additional Authenticator” notice, and a hosted enrolment and management page that ADR-014’s P5 reuses; the amendment makes REMOTE_APPROVAL_ENABLED require WEBAUTHN_ENABLED. The RP ID (WEBAUTHN_RP_ID) defaults to the realm’s exact host. A parent domain is allowed only by explicit operator opt-in, with a warning (ADR-019). Per-client origins are operator-set. When the RP ID is the realm’s host, QAuth serves the platform association files (apple-app-site-association, assetlinks.json) from operator-set app identifiers, so one passkey works across every first-party app and the hosted /ui/login. F2b also clears the first gate of ADR-009 §3 “rp-pseudonym is the intended endpoint, and is gated” (“a WebAuthn workstream”).
  • Wallet, agents and MCP. WalletProvider.verify() keeps throwing; wallet login is never a challenge step, only a redirect_to_web target. first_party_profile excludes is_agent by CHECK, so a challenge-issued code never roots an Authority Tree (ADR-014 §1 “Session root — tree_id on agent access tokens”). DCR and CIMD clients cannot be first-party, so MCP clients reach the endpoint only as unauthorized_client. draft-embesozzi-oauth-agent-native-authorization-00 binds MCP Elicitation to FiPA; it would have a third-party agent collect a user’s credentials, the case FiPA §1.1’s MUST covers, so it stays a watch item. OpenID Connect Native SSO for Mobile Apps (Second Implementer’s Draft) is F5, for several first-party apps on one device.

Why. ADR-007 keeps its identity; ADR-014’s P1a and P5 need the same two components, and two verifiers or two providers would have to be kept in step.

Alternatives considered

AlternativeWhy not
Web front-ends as a per-client operator opt-in with compensating controlsA knowing breach of RFC 10017 §7.3 (MUST) and FiPA §9.8 (NOT RECOMMENDED) in the first release; §9.8’s user-experience reason does not hold on the web. Declined on 2026-10-08 (question 1 keeps its mitigation list).
Argue that a server-rendered web app is not a browser-based applicationRFC 10017 §3 turns on “dynamically downloaded and executed in a web browser”; a framework app with server actions, such as QAuth’s own portal, is one.
Only attested native apps, no backendsBlocks the first release on a draft whose WGLC ended 2026-09-22, and leaves the main case, an app backend, unserved.
Keep extending the /auth/* JSON routesA proprietary surface with no client authentication and no step signalling; FiPA Appendix C “Design Goals” puts the answer in an endpoint that returns a code.
One boolean first_party, or two booleansCannot separate the redirect-flow consent waiver from headless access, and cannot state the front-end scope.
Infer first-partyness from dynamic_registered_at IS NULL, or a client-id allowlist in configurationSelf-service /api/clients rows are NULL there too; a configuration list has no CHECK tying it to auth method, agent flag or registration type.
DCR with a software statementA trust edge created by the party being trusted is not a trust edge (ADR-011 §5).
Public native clients on DPoP alone before attestationDPoP proves key continuity, not app identity; it cannot meet the FiPA §5 MUST for such a client.
QAuth-defined error values per step or per rejectionAgainst the editors’ position on WG issue #133; custom values collide (WG issue #171); a generic client stops on an unknown error.
interaction_types_supported required, 400 when absentDead-ends a generic FiPA client; redirect_to_web is the draft’s own escape.
Pick the first step from the account’s strongest method after login_hintDiscloses account existence and passkey holders before any factor.
Accept and ignore acr_valuesDrops an RFC 9470 §5 SHOULD and invites the loop it warns about.
Email code after every password sign-in in F1Removes the one-call happy path apps rely on; kept as a per-client option.
Passwordless email-code sign-inNIST SP 800-63B-4 §3.1.3.1; contradicts the downgrade rule while every account has a password.
Quiet suppression at every send cap, the per-address send ceiling includedA third party could spend an address’s cap and silently drop the owner’s reset and verification mail. The send ceiling and the per-address-and-client tier count unknown addresses too, so their 429 is the same for every address and reveals nothing; only the tier keyed on an address and an IP stays quiet.
Keep the unclaimed account’s sub and remove only its credentialsState that downstream apps keep under that sub would reach the mailbox owner (account pre-hijacking).
Reset a lost second factor by emailTurns a mailbox takeover into a second-factor bypass; a recovery code or an admin rebinds it instead.
DPoP for every client in the first releaseBlocks F1 on a verifier QAuth does not have as of 2026-09-26, and adds little for a confidential backend.
Long-lived post-code sessions for step-up (FiPA §6.1 “Token Endpoint Successful Response”) in F1The right later design, but long-lived state in a first release; deferred to F5.
A form parameter or TRUST_PROXY for the end-user IPA form parameter is workable, but the maintainer chose the signed claim on 2026-10-09 (question 4); TRUST_PROXY makes every route believe X-Forwarded-For from a backend on the public internet.
Bot challenge mandatory for registration in the first releaseMakes F1 depend on a third-party provider and an unbuilt egress helper; F1b, per client.
amr: ["pwd"] after a resetThe user set a password; they did not prove one.
A flag-gated grant_type=password for compatibilityEither not ROPC or not safe (Decision 12); RFC 9700 §2.4.
A backend callback URL for the browser legA backend that completes from state alone cannot tell which browser returned the code.
Fold ADR-017 into ADR-014, or wait for itPuts human login behind AUTHORITY_TREE_ENABLED, or loses the window for apps leaving ROPC.

Standards position

Positions: Conformant, Stricter (QAuth requires more), QAuth-defined (an extension the text allows), Knowing deviation (recorded, with an end or a decided question), Partial (part of the text is met; the row names that part, and the phase or question for the rest), Shared responsibility (QAuth meets its part; the rest is a profile requirement on the vendor’s app), Not claimed (QAuth makes no claim the text governs), Deferred.

PiecePositionHook
https endpoint; client authentication at the endpointConformantFiPA §4.1 MUSTs
Accept authorization-request parametersConformant: state accepted and unused; login_hint new handling; prompt=create from F1FiPA §4.1 “Authorization Challenge Endpoint” MUST
Verify first-partynessConformant (operator profile and private_key_jwt; attestation for native apps in F4)FiPA §5 MUST; §9.1
Browser leg: external user agentShared responsibility until F4: a profile requirement, shown in the guide; the app code is the vendor’sFiPA §5 SHOULD; RFC 8252 §8.12
Browser leg: redirect URI and completionConformant as a profile requirementFiPA §5 SHOULD; RFC 8252 §7.1–§7.3, §8.9
PKCEStricter by default: S256 required; FiPA makes it OPTIONAL. The redirect flow always requires it. An operator may switch it off for one client at this endpoint only; that is a knowing deviation from QAuth’s own rule, still conformant with FiPA §5.1, with a boot warning (question 11)FiPA §5.1; RFC 9700 §4.8.2 “Countermeasures”
Next-step signalQAuth-defined members inside insufficient_authorizationFiPA §5.2.2 MAY; WG issue #133
403 for insufficient_authorization and redirect_to_web; 400 invalid_sessionConformant (WG issue #177 open)FiPA §5.2.2.1 MUST; §5.2.2 default
429 for throttling, 503 for store outagesQAuth-defined: outside §5.2.2’s default of 400, which applies “unless specified otherwise by a particular error code”FiPA §5.2.2
error valuesStricter: only values that a specification defines, within the character set that FiPA §5.2.2 sets as a MUST NOT. FiPA §5.2.2 lets the AS “extend these error codes”; QAuth defines no values of its own. The same section “requires the authorization server to define new error codes” for the client’s next actions. QAuth carries those as members, as FiPA Appendix B.2 “Authorization Challenge Response Parameters” does and as an editor preferred on WG issue #133FiPA §5.2.2 “Error Response”; Appendix B.2; WG issue #133; WG issue #171
iss on every responseQAuth-defined on the 200FiPA §5.2.2.1.1 SHOULD (RFC 9207) for the browser leg
auth_session device bindingPartial for backends (bound to client authentication); DPoP F3; instance key F4FiPA §5.3.1 SHOULD; §9.6
Client keeps auth_session beyond the codeConformant in the SDK: the last handle is kept per sessionKey until logoutFiPA §5.3.1 “Auth Session” MUST
Client includes a stored auth_session in later requestsKnowing deviation in the SDK until F5 (question 17): the SDK does not present the last handle on a new transaction, because QAuth ends every auth_session at the code; a client that presents it gets invalid_session and pays one round trip (Decision 6)FiPA §5.3.1 “Auth Session” MUST; §9.6.2 “Auth Session Lifetime”
redirect_uri at the token endpointConformant. Browser codes: required and identical (RFC 6749 §4.1.3). Challenge codes: the OAuth 2.1 -16 rule, absent or identical to a value sent on the authorization challenge request; RFC 6749 §4.1.3’s presence rule is not applied to them (WG issue #179 open as of 2026-09-26)FiPA §6; RFC 6749 §4.1.3; OAuth 2.1 -16 §4.1.3, §10.2
Client assertion audConformant for every client at every endpoint: the issuer as sole value (ADR-011 §7 at /oauth/token since 2026-10-11, this record at the endpoints it adds). The SDK sends the issuer as sole value at every endpointdraft-ietf-oauth-rfc7523bis-11 §4 “Updates to RFC 7523” MUST, MUST NOT
acr_valuesConformant: refused with unmet_authentication_requirementsRFC 9470 §5 SHOULD; §7
Attestation for native appsDeferred to F4 (not applicable to backends)FiPA §9.3, §9.4 SHOULD
Sender-constrained tokensKnowing deviation for native_backend until F3. From F3, DPoP is on by default; a client whose operator switched it off keeps the deviation, recorded for that client with a boot warning (question 24). F4: DPoP for attested appsFiPA §9.5 SHOULD
Identical experience across first-party appsShared responsibility: AS behaviour, StepView and SDK; app UIs are the vendor’sFiPA §9.7.1 MUST; §9.7.3 RECOMMENDED
Browser-based applicationsConformant (native front-ends only; the web variant was declined on 2026-10-08)FiPA §9.8 NOT RECOMMENDED; RFC 10017 §7.3, §6.1.3.1 MUSTs
Registration inside the flowQAuth-defined, following the non-normative example with verify-firstFiPA Appendix A.8
Headless resetQAuth-defined: an email-recovery sign-in at mailbox assurance, with no amr or auth_timeFiPA Appendix A.2, §5.2.2.1.1; NIST SP 800-63B-4 §4.2
No ROPCConformant at /oauth/token, which has never offered grant_type=password; QAuth’s own password-to-token route, /auth/login, is removed in F1c (Decision 12)RFC 9700 §2.4 “Resource Owner Password Credentials Grant” MUST NOT; OAuth 2.1 -16 §10 “Differences from OAuth 2.0”
Asymmetric client authenticationStricter (private_key_jwt only)RFC 9700 §2.5 RECOMMENDED
Forwarded end-user IPQAuth-defined signed claim; the sanitising duty falls on the backend’s ingress and is a profile requirementRFC 9700 §4.13
Code replayConformant (deny and revoke the family)OAuth 2.1 -16 §4.1.3 MUST, SHOULD
invalid_dpop_proof, use_dpop_nonce at this endpoint (F3)QAuth-defined use beyond the registered locationsRFC 9449 §12.2 “OAuth Extensions Error Registration”
interaction_types_supported, interaction_type_requiredKnowing deviation: the names are borrowed and the rules differ. OpenID4VCI requires the list and says an AS that cannot serve it “MUST reject the request”; QAuth answers redirect_to_web, as FiPA allows (Decision 3)OpenID4VCI 1.1 “Initial Request”; “Interaction Required Response”; “Authorization Challenge Error Response”
Step values “MUST be valid URNs”Knowing deviation: urn:qauth: is not a registered namespace; the names become stable through /interaction/v1, and standard names would be added as aliases (question 18)OpenID4VCI 1.1 “Initial Request”
prompt=create metadata and ID tokenConformant from F1’s releaseOIDF prompt=create §4.1, §4.2 MUSTs
Administrative consentConformantOpenID Connect Core 1.0 §3.1.2.4
amr vocabularyConformant (RFC 8176 and IANA pop; no email value)RFC 8176 §2; RFC 9068 §2.2.1
Password verifiersConformant for new and changed passwords at the default minimum of 15 (whole-password blocklist); an operator-set minimum below 15 is a knowing deviation while a password can be the only factor; Stricter by a zxcvbn score floor, which requires no character mix and so is not read as a composition rule; blocklist partial until a breach corpus (F5)NIST SP 800-63B-4 §3.1.1.2
Email as an authenticatorNot claimed: no amr, acr, mfa or AALNIST SP 800-63B-4 §3.1.3.1 SHALL NOT
Email code parametersConformant as address-verification and issued recovery codes: each code is disabled after 5 wrong answers; across codes, a per-identifier wait ladder, not a stop, so the recovery path stays open. The optional per-client confirm_sign_in check is outside the §3.1.3.1 carve-out and claims no authenticator status (no amr, acr, mfa or AAL)NIST SP 800-63B-4 §4.2.1.2 “Issued Recovery Codes”; §3.2.2; §3.1.3.1
ThrottlingConformant for passwords and TOTP while their hard actions are armed: for passwords by default (question 7), for TOTP while the hosted pages are on and the account keeps an unused recovery code, or keeps another usable second factor. Every counter is reserved before the verifier runs. Emailed and recovery codes have a ladder and no stop, so the recovery path stays open: an emailed code dies after 5 wrong answers, and a recovery code carries at least 64 bits. Partial in a deployment, or for an account, where a hard action is not armedNIST SP 800-63B-4 §3.2.2

Watch list

DocumentRevision · dateExpiresWhy watched
draft-ietf-oauth-first-party-apps (WG)-04 · 2026-07-012027-01-02the profile’s base; the four WG issues named in The draft this record builds on are open
draft-ietf-oauth-attestation-based-client-auth (WG)-11 · 2026-09-032027-03-07F4; WGLC ended 2026-09-22; F4 builds on its post-WGLC revision
OpenID4VCI 1.1 “Interactive Authorization” (OIDF editor’s draft)openid-4-verifiable-credential-issuance-1_1-01 · repository commit 8cb80d2 of 2026-09-25—negotiation names; OpenID4VCI issue #595
draft-ietf-oauth-v2-1 (WG)-16 · 2026-09-032027-03-07token request without redirect_uri; replay rule; ROPC omitted
draft-ietf-oauth-rfc7523bis (WG)-11, in the RFC Editor queue per the IETF index of 2026-09-26—aud = issuer as sole value; the token endpoint’s accepted values; also watched by ADR-014
draft-embesozzi-oauth-agent-native-authorization (individual)-00 · 2026-04-032026-10-05MCP elicitation over FiPA
OpenID Connect Native SSO for Mobile Appsdraft 07 · 2025-01-16 (Second Implementer’s Draft)—F5
OIDF Initiating User Registration via OpenID Connect 1.0Final · 2022-12-02—prompt=create from F1

Rows for these documents go into docs/spec-pin-log.md in a follow-up change, not in this record.

Explicitly out of scope

A password grant in any form. Headless login for web front-ends (question 1, decided 2026-10-08). Password-hash import in formats other than Argon2id PHC (question 5, decided 2026-10-09); those users sign in through upstream OIDC login. SMS and phone steps. User-name and phone-number identifiers. Social or wallet steps inside the flow (they are redirect_to_web targets). An admin user API or an operator role. OpenID Connect Native SSO and an MCP-elicitation binding (later). SSO carry-over from a headless login into the browser: FiPA is silent on it, and the user meets /ui/login fresh.

Phasing

The rule for every phase: with the phase’s switch off (for F0, for every client an operator has not flagged), nothing changes except the deltas listed for that phase. Each listed delta has its own test, and every other response, token and discovery document stays byte-identical to the previous release. The switch is usable only when F1 is complete. F1 lands as several changes. Until the change that completes F1 (its end-to-end suite), the environment schema refuses FIRST_PARTY_LOGIN_ENABLED=true with a clear boot error. That change removes the refusal, so the route can never be switched on before its abuse controls exist. WEBAUTHN_ENABLED follows the same pattern in F2b. The switch gates only the FiPA route: Decision 7’s controls and Decision 8’s rules apply to every transport that reaches the engine, from the first release that ships it, whatever the switch says.

The F1 release train is the run of releases in which F1’s changes land. It ends with F1’s release: the release that removes the refusal, so an operator can first switch FIRST_PARTY_LOGIN_ENABLED on. A phase whose target is the train ships no later than F1’s release.

  • F0 — prerequisites, no new endpoint, no switch.
    • Contents: Decision 10’s data model, mintAuthorizationCode, the token-endpoint redirect_uri rule and replay revocation; Decision 2’s profile, CHECKs, resolver, seed fields and pins; private_key_jwt at revocation and introspection; administrative consent at /oauth/authorize and the limited hand-off (Decision 9); a fresh login at /oauth/authorize for native profiles (Decision 9); an atomic GETDEL; a browser end-session route (id_token_hint, a registered post_logout_redirect_uri; it clears __Host-qauth_session) per OpenID Connect RP-Initiated Logout 1.0 §2 “RP-Initiated Logout” and §3.1 “Client Registration Metadata”; OpenID Connect Back-Channel Logout 1.0 beside the end-session route (ADR-019): QAuth sends a logout token to every app with a session that registered a backchannel_logout_uri (§2.2 “Indicating RP Support for Back-Channel Logout”), through the egress client of ADR-019 Decision 8, which checks the URI at registration and again at every delivery; the portal’s move. The SYSTEM_CLIENT_ID row is re-provisioned as a private_key_jwt web_redirect client with the portal’s callback. The migration-runner job and the seed provisioner do this from operator configuration. The portal refuses to start on any other row shape and takes authorization_endpoint from discovery. It keeps its tokens server-side, so its cookie (__Host-qauth_portal_session) carries only a session id. It signs out by revoking its refresh token and calling the end-session route. It keeps “sign out everywhere” as an explicit action (question 9; Decision 8 says what it ends) and keeps its “Create account” link until F2a.
    • Listed deltas: (1) a replayed code revokes its family, for every client; (2) discovery gains private_key_jwt for revocation and introspection, the matching revocation_endpoint_auth_signing_alg_values_supported and introspection_endpoint_auth_signing_alg_values_supported members, end_session_endpoint and backchannel_logout_supported; (3) the headless hand-off follows Decision 9; (4) a browser-code token request without redirect_uri answers invalid_grant (Decision 10) instead of a schema VALIDATION_ERROR; (5) a new end-session route and back-channel logout; (6) the portal signs in through the redirect flow and keeps its tokens server-side.
    • Exit criteria:
      • Byte identity except the listed deltas.
      • The CHECKs hold: no browser code without redirect_uri; no profile from DCR, CIMD or /api/clients, or with is_agent, a developer or another auth method.
      • A private_key_jwt client revokes its own refresh token, and a token-URL aud is refused there. A flagged client’s token-URL aud is also refused at /oauth/token.
      • Management tokens from the re-provisioned row pass assertManagementToken.
      • A native_backend client at /oauth/authorize with a live browser session and no prompt meets the login page, and with prompt=none gets login_required.
      • Sign-out then sign-in asks for a password.
    • Dependencies: none on ADR-014; the revocation and introspection work waits only for the text of the ADR-014 amendment, so the two records never disagree.
  • F1 — first release, behind FIRST_PARTY_LOGIN_ENABLED=false.
    • Contents: Decisions 1–11 for native_backend with the F1 steps; flows per client, inline verification, the unclaimed-account rule and the session epoch; the one password policy; @qauth-labs/node, published to npm with provenance, and the language-neutral kit; the guide, API reference and migration guide; an operate checklist line to pass 400 and 403 bodies through unchanged on the endpoint; an example in the repository: a Node backend and a minimal desktop or command-line app that renders StepView.
    • Listed deltas with the switch off: (1) /auth/register applies the new password policy; (2) /ui/login and /auth/login reserve and count on the engine’s per-account ladder beside their own lock, and refuse a round while its store is unavailable (Decision 7).
    • Exit criteria:
      • The example app signs up, verifies, signs in, resets and signs out with no browser.
      • A listed backend at its expected volume is not throttled at the authorization challenge endpoint, /oauth/token or /oauth/revoke, and an unlisted peer keeps the route caps.
      • A fallback case works with the example app: no negotiation, redirect_to_web, system browser, loopback return, the app posts to its backend, tokens. The guide shows the app-claimed https return for mobile apps.
      • Every row of the status table has a test.
      • The anti-enumeration invariant holds in bodies and timing, including reset and registration starts.
      • With the switch off, discovery is byte-identical except the listed deltas.
      • @qauth-labs/node installs from npm.
      • The disable ceiling is armed only while F2a’s hosted reset is on, and a test shows that a mark set before hosted pages go off is ignored and no longer blocks sign-in.
      • 200 parallel wrong passwords for one account, each in its own transaction, are verified no more often than the same attempts sent one at a time. The client, end-user IP and send-cap counters hold the same way.
      • A third party who spends an address’s send tiers from other IPs and clients cannot stop the owner’s reset: a known IP of a verified account still gets the mail, and the pass in the notice takes one reset start past the send ceiling. At the per-address send ceiling every address, existing or not, gets the same 429, and the operator is alerted.
      • Sign-ins to one account count once toward a client’s failure ratio, and the absolute failure budget triggers the bot challenge and the alert at any ratio.
      • With the switch off, the hosted pages and the legacy password surfaces meet the same ladder, disable ceiling, send caps, timing floor and fail-closed stores as the endpoint.
      • Where the realm’s or client’s MFA policy requires a second factor, a password-only account gets no code on either transport, and a policy change reaches a live transaction.
      • The first mailbox proof on an unclaimed account yields a new sub, removes every row of the old user except its audit rows, which keep the old id, and sends a back-channel logout for the old sub.
    • Dependencies: F0. None on ADR-014. No third-party provider.
  • F1b — bot challenge (same switch): the provider-neutral verifier, the built-in proof-of-work provider, the SSRF-safe POST helper for an operator’s hosted provider, urn:qauth:ia:captcha, and a per-client requirement for registration. Depends on F1. F1b is in 1.0 and stays provider-neutral (question 15, decided 2026-10-09). The default provider and the operator’s option were decided on 2026-10-11 (Decision 7).
  • F1c — removal of /auth/login, /auth/logout and the headless hand-off (question 19, decided 2026-10-09), and of /auth/register (Decision 12). They are removed directly, with no freeze phase. Removal comes as soon as F0’s portal move and the CLI client for scripted management tokens (question 8) exist. /auth/register also waits for F2a’s hosted sign-up page. All of this lands before 1.0.
  • F1d — user import, a switch-free operator tool (question 5): db:import-users in the seed-script pattern; records carry the address, whether the source proved the account’s primary identity, and an Argon2id PHC hash, which QAuth’s hasher already verifies; each import writes user.imported. An imported account counts as verified only if the source proved its primary identity. A record in any other hash format is not imported. The tool lists it in its report. That user signs in through upstream OIDC login, with the old provider as the upstream (Decision 12). A record may also carry the source IdP’s subject identifier. The tool then writes a report that maps it to the new sub, and the user.imported row records it. An app that keys its data by the old issuer and subject re-keys from that report, not by address (OpenID Connect Core 1.0 §5.7 “Claim Stability and Uniqueness”). Depends on F0. Target: the F1 release train, so the migration guide that ships with F1 can name the tool.
  • F2a — hosted account pages with no WebAuthn dependency (question 6, decided 2026-10-09): registration, verification and reset pages on the F1 engine, and an account page. They live in the reference ceremony app, a separate app that uses only the Interaction API (ADR-019). The reset, verification and account pages are on whenever hosted pages are on. Only sign-up sits behind its own switch, and it is off by default. F2a also adds a “Forgot password” link on /ui/login, a “Create account” link while sign-up is on, and prompt=create at /oauth/authorize. While sign-up is on, the portal signs up through the hosted sign-up page by redirect. The account page offers “sign out everywhere” (question 9). A legacy route is removed only once its hosted replacement is on for the portal in a default deployment. /auth/register is the exception: F1c removes it although hosted sign-up is off by default (Decision 12). Depends on F1’s registration and reset flows. A hosted reset or registration sets no auth_time. It ends without a browser session that could satisfy max_age or prompt=login (Decision 10). Target: the F1 release train.
  • F2b — passkeys, behind WEBAUTHN_ENABLED=false: the shared WebAuthn CredentialProvider; webauthn_get and webauthn_create; passkey-first; registration of an account whose primary identity is a passkey, with no address (question 30); recovery codes, issued at the first passkey enrolment and redeemed on the hosted page, while the FiPA endpoint answers redirect_to_web for that recovery (question 25), as Decision 8 specifies them and Decision 7 throttles them; the downgrade rule binding; UV verified server-side; optional enrolment; the hosted enrolment and management page shared with ADR-014’s P5; the RP ID (the realm’s exact host by default, a parent domain only by explicit operator opt-in with a warning), per-client origins and the association files, after re-reading the platform rules; passkeys on the hosted /ui/login on the same engine. Depends on F1, and on F2a for hosted passkey login.
  • F2c — TOTP, in the 1.0 train. It moved out of F5 on 2026-10-09, to match the 1.0 scope (ADR-018). It adds the totp step and its amr (Decisions 3 and 10); TOTP’s place in the step order, the reset and the unclaimed-account rule (Decisions 4 and 8); Decision 7’s second-factor row for TOTP and recovery codes; and Decision 8’s TOTP rules: the secret under the data-at-rest key, enrolment confirmed by a first code, a used time step refused, fresh authentication to bind, remove or replace, and a recovery-code set at the first TOTP enrolment. Exit criteria: wrong TOTP codes after a correct password, across transactions, clients, transports and IPs, are verified no more often than the ladder allows, and disable TOTP at 100 only while the hosted pages are on and a recovery code remains, or another usable second factor does; a correct password never clears that counter; an accepted code is refused on its second use. Depends on F1.
  • F3 — DPoP, behind DPOP_ENABLED=false: the shared verifier; FiPA §9.5.1 code binding and §9.6.1 session binding at the endpoint, which ends its DPoP refusal; cnf.jkt, token_type: DPoP and nonces at the token endpoint; the shared dpop_bound_access_tokens column; DPoP on by default for native_backend clients, with a per-client operator switch-off and its boot warning (question 24). Depends on F1; shares the verifier with ADR-014’s P1a.
  • F4 — attested native apps, behind CLIENT_ATTESTATION_ENABLED=false (which requires DPOP_ENABLED): attestation -11, or its post-WGLC revision, in DPoP combined mode; the native_attested profile, with the auth-method CHECK replaced so that native_attested is tied to attest_jwt_client_auth[_dpop] and the other profiles stay on private_key_jwt; attester keys per client (CLIENT_ATTESTATION_TRUSTED_ATTESTERS, in the pattern of ID_JAG_TRUSTED_ISSUERS); instance-bound refresh tokens; the attestation metadata; public native clients may become first-party only here; the Flutter SDK in a separate repository. F4 is in 1.0 if the attestation draft becomes an RFC before 1.0 is cut. Otherwise F4 stays experimental (question 20, decided 2026-10-09).
  • F5 — later, unscheduled: an established post-code auth_session and FiPA §6.2 refresh-time step-up; a pushed-request request_uri (RFC 9126 §2.2 “Successful Response”) in redirect_to_web once WG issue #179 settles; the FiPA Appendix A.4 “Email Confirmation Code” link variant; amr on browser logins; a breached-password corpus; an authenticator-strength acr; OpenID Connect Native SSO.
What the first release refusesAnswerLifted in
Public client (none)400 unauthorized_clientF4
Secret-based client; DCR, CIMD or agent client; web_redirect profile400 unauthorized_clientnever
DPoP or OAuth-Client-Attestation headers400 invalid_requestwhen the endpoint binds them (F3, F4)
Browser-originated request (Origin, Sec-Fetch-Site)400 invalid_requestnever
Missing qauth_end_user_ip claim400 invalid_requestnever for native_backend
A frozen authorization-request parameter on a later round400 invalid_requestnever
acr_values400 unmet_authentication_requirementswith an ADR-010 amendment
prompt=none400 login_requiredF5, with established sessions
prompt=consent, dangerous scopes403 redirect_to_webnot planned
Reset or registration under max_age or prompt=login403 redirect_to_webnever
No interaction_types_supported403 redirect_to_webnever
Passkey, TOTP and bot-challenge stepsnot offeredF2b, F2c, F1b
Passwordless email-code sign-innot offerednever (question 2)
request_uri in redirect_to_webnever returnedF5
auth_session after a code; token-endpoint insufficient_authorizationinvalid_session; never emittedF5

Consequences

Positive

  • Apps get native sign-in, sign-up and reset with no browser and no password grant, on a WG draft’s endpoint rather than a proprietary one.
  • QAuth, not each app, owns throttling, verification, recovery, step-up and the password policy.
  • The happy path keeps a password grant’s shape: one call to the authorization challenge endpoint and one token call.
  • Passkeys (F2b) arrive on the same wire with no new endpoint. A user who signs in with a passkey gives the app no password.
  • QAuth applies the rule to itself: its portal moves to the redirect flow in F0, and its password-to-token route retires in F1c.
  • Every browser code QAuth mints is PKCE-bound by construction. So is every challenge code, unless an operator switched PKCE off for that client (question 11). Every code replay revokes its family, for every client. Revocation and introspection gain private_key_jwt, which ADR-014 also needs.

Negative

  • A new endpoint with a step engine, a session store, a layered limiter and an SDK is a large surface, and the most branch-heavy login path QAuth has.
  • A password step shows the password to the app. Passkeys (F2b) avoid this only for users who sign in with one. native_backend access tokens stay Bearer until F3, a recorded deviation from FiPA §9.5 “Sender-Constrained Tokens”. From F3 the deviation remains only where an operator switched DPoP off for a client.
  • QAuth cannot verify the asserted end-user IP, only who asserted it.
  • Headless login is unavailable while Redis is unavailable, by design. So is sign-in on every other surface of the engine, and on the legacy password surfaces, while the counter store is down.
  • /auth/register and /auth/login stay until F1c. Once /auth/register is gone, a default deployment has no sign-up page, because hosted sign-up is off by default (question 6). An operator who wants sign-up turns it on.
  • The app changes too, not only its backend. It renders the email_code, new_password and redirect_to_web steps. After a correct password, a user whose account is not yet verified (for a password account, its address is unproved) is asked for an email code. This includes an imported account whose source did not prove its primary identity. New passwords need 15 code points. An app build that cannot render these steps cannot complete them, so the build that renders them ships before its backend switches.
  • An app whose backend passed password-grant tokens to its device must route its API calls through the backend until attested apps (F4).
  • The one password policy changes /auth/register on every deployment in F1 (15 code points), and integrations must show the new rejection reasons.
  • An account that was used without ever being verified ends at the first reset or registration of its address (Decision 8): its sub, consents, API keys and owned clients do not carry over, and an imported account’s report mapping no longer applies. Signing in with the password verifies it in place and keeps it, so F1’s release note asks the owners of such accounts, developers who own clients first, to sign in and verify before they reset.
  • QAuth ships and versions an npm package for the first time, with its release process; the README’s planned browser-side signInWithPassword will not ship.

Neutral

  • FiPA is a WG draft. The profile absorbs likely changes (status codes, negotiation, custom members) by emitting core equivalents beside its own members and classifying by error. The endpoint and the SDK’s first-party subpath stay experimental until FiPA is an RFC. The engine is part of the 1.0 promise, because the hosted pages use it (ADR-018).
  • ADR-007’s positioning is unchanged; human login is a product line beside it and feeds no agent path. acr semantics are unchanged (ADR-010); amr appears only on tokens from challenge-issued codes until F5.
  • WalletProvider.verify() keeps throwing; wallet login stays on its own seam.
  • The ADR-014 amendment is a separate, dated change after this record is accepted.

Decided by the maintainer

  1. Web front-ends behind a backend. FiPA §9.8 and RFC 10017 §7.3 and §6.1.3.1 reach them. Should QAuth offer them as a per-client, operator-set, recorded deviation? The minimum would be an operator value web_backend with a written reason; RFC 10017 §6.1.3.2 “Cookie Security” and §6.1.3.3 “Cross-Site Request Forgery Protections”; a content security policy without unsafe-inline and no third-party script on credential pages; passkey-first with the web origin as the WebAuthn origin; a mandatory end-user IP and a bot challenge at thresholds; lower per-client limits; redirect_to_web on any risk signal; dangerous scopes always refused; audit rows tagged as web; and the ADR naming the deviation. Decided 2026-10-08: no. Web front-ends use the redirect flow at /oauth/authorize, and hosted pages (F2a) are the web answer. The FiPA endpoint serves first-party native apps only.

Questions 2–10 were the true forks. Each keeps its question and the default this record proposed. The maintainer’s decision follows in italics.

  1. Email codes: a sign-in factor, a check after every password, or passwordless sign-in? Proposed default: address verification and recovery, plus an optional per-client check after the password (email_code_after_password: false). They never produce amr, acr, mfa or an AAL claim. Passwordless email-code sign-in is not offered. A reset of a password-only account is labelled an email-recovery sign-in. Decided by the maintainer, 2026-10-09: emailed codes are never a sign-in factor. The per-client check after the password exists only at the experimental FiPA endpoint. The Interaction API never offers it.
  2. Password minimum on /auth/register. Proposed default: the 15-code-point minimum applies to every newly set password, including /auth/register, as an announced F1 change on every deployment, with a release note and the portal form showing the new reasons. An operator may lower it to 8, a knowing deviation while a password can be the only factor. Alternatives: gate it behind the switch, or keep the legacy route on its old rule until F2a. Decided by the maintainer, 2026-10-09: the 15-code-point minimum applies to every newly set password, /auth/register included. It is an announced F1 change. Operators may lower it to 8. Existing passwords are kept.
  3. End-user IP carriage. Proposed default: the qauth_end_user_ip claim inside the signed assertion, derived by the backend from its peer or a listed proxy chain. Alternatives: a form parameter from an authenticated first-party client; accepting either carrier is not recommended (two parsers and a precedence rule for one security input). Decided by the maintainer, 2026-10-09: the end-user IP travels as a claim in the signed client assertion. It belongs to the FiPA endpoint only.
  4. Users migrating from another IdP. Proposed default: an operator import tool in F1d, in the seed-script pattern (the only operator surface; ADR-012 §4); Argon2id PHC hashes only, with bcrypt and PBKDF2 only after the dependency and advisory check; addresses verified only if the source says so; a source subject identifier, when given, is mapped to the new sub in the tool’s report; audited as user.imported. Alternative: no import, with the migration guide saying so and naming the register flow as the bulk path, since reset mails only an existing account. Lazy migration against the old IdP is rejected, because it keeps a password grant alive there. Decided by the maintainer, 2026-10-09: the import tool stays, for Argon2id hashes only. An imported account counts as verified only if the source proved its primary identity. Users whose hashes cannot be imported sign in through 1.0’s upstream OIDC login, via their old provider.
  5. Hosted account pages: timing and switch. Proposed default: the hosted registration and reset pages ship in the F1 release train, independent of WebAuthn, behind their own default-off switch, because opening hosted registration is an operator decision. Decided by the maintainer, 2026-10-09: the switch is split. The reset, verification and account pages are on whenever hosted pages are on. Only sign-up is off by default.
  6. Arming the disable ceiling. NIST SP 800-63B-4 §3.2.2 says disabled authenticators “SHALL be required to rebind”. Proposed default: the disable at 100 is armed only while every account it can reach has a self-service rebind path, checked at run time. The headless reset serves native users only, so in practice the ceiling is armed only while F2a’s hosted reset is on. Otherwise the soft ladder runs alone, marks set earlier are ignored, and the standards position records §3.2.2 as partial. Decided by the maintainer, 2026-10-09: the default is kept. The ceiling is active only while self-service recovery exists. With question 6, it is active by default.
  7. Scripted management tokens after F1c. The public API reference names /auth/login as the source of the developer token. Proposed default: before F1c, ship an operator-listed public CLI client with no first_party_profile, so the CHECKs of Decision 2 stay unchanged and the ordinary consent rules of Decision 9 apply. It uses the code flow with a loopback redirect (RFC 8252 §7.3 “Loopback Interface Redirection”); as of 2026-09-26 redirectUriMatchesRegistered accepts any loopback port. assertManagementToken accepts its tokens through an operator-set, seed-only allowlist field. Alternative: record that scripted management ends at F1c. Decided by the maintainer, 2026-10-09: an operator-listed public CLI client gives scripts their management tokens. It uses the code flow with PKCE and a loopback redirect. It ships before 1.0.
  8. “Sign out everywhere”. As of 2026-09-26 /auth/logout revokes every refresh token of the user. Proposed default: the portal keeps that action after F0, through a management-API call guarded by assertManagementToken; it revokes every refresh family and advances the browser-session epoch. The ordinary sign-out ends only the current session. Decided by the maintainer, 2026-10-09: “sign out everywhere” revokes the user’s session rows in Postgres and clears the Redis cache. It sends a back-channel logout to every app with a session. The hosted account page offers it too.
  9. ADR-014 amendment. Proposed default: yes, as a separate dated change after this record is accepted: DPoP under DPOP_ENABLED, which AUTHORITY_TREE_ENABLED requires; a shared dpop_bound_access_tokens; one flag-neutral WebAuthn provider with one hosted enrolment and management page; REMOTE_APPROVAL_ENABLED requires WEBAUTHN_ENABLED; revocation and introspection private_key_jwt moved to ADR-017 F0. Decided by the maintainer, 2026-10-09: one DPoP verifier behind DPOP_ENABLED and one WebAuthn provider, both shared with ADR-014. private_key_jwt at revocation and introspection lands in F0.

Questions 11–30 recorded defaults that were unlikely to change. The maintainer decided each of them on 2026-10-09. The table keeps the proposed default beside the decision.

#QuestionProposed defaultDecided by the maintainer, 2026-10-09
11PKCES256 required, recorded as Stricter; mintAuthorizationCode refuses to mint without itPKCE S256 stays mandatory on the redirect flow, always. At the FiPA endpoint it is mandatory by default; an operator may switch it off per client, with a boot warning and a recorded deviation
12FiPA §1.1the profile’s rules are stated as QAuth’s own requirementsthe profile rules are QAuth’s own requirements
13Registration gateper-client flows; realms.registration_allowed stays unread; F2a extends the per-client list to hosted registrationregistration needs both the realm’s registration_allowed and the client’s flows, for hosted and native sign-up
14prompt=createthe path marker in F1; prompt=create with its metadata in F2athe standard prompt=create is used from F1, instead of a path marker, with prompt_values_supported in discovery
15Bot challenge for registrationnot required in F1; per client from F1bthe bot challenge (F1b) is in 1.0, provider-neutral
16Consecutive-failure ceiling100 with the soft ladder; lower values make lockout cheapera ceiling of 100 with the soft ladder
17Established post-code auth_session (FiPA §6.1, §6.2)none before F5; step-up is a fresh transactionno post-code auth_session before F5; step-up is a fresh transaction
18Step identifier namespaceurn:qauth:ia:, recorded as a deviation; revisit if a WG type registry appearsthe urn:qauth:ia: step names become stable through /interaction/v1; standard names, if a registry appears, are added as aliases, never renames
19Hand-off and legacy removallimited in F0, frozen in F1, removed in F1c; /auth/login refuses passkey-bound accounts until then/auth/login and the headless hand-off are removed directly, with no freeze phase, as soon as F0’s portal move and the CLI client of question 8 exist, all before 1.0; until removal, /auth/login refuses passkey-bound accounts
20Attestation revisionwait for the post-WGLC revision of -11, re-pin, then build F4F4 goes into 1.0 if the attestation draft becomes an RFC before 1.0 is cut; otherwise F4 stays experimental
21Pre-attestation native clientsrefused in every environment until F4; no value exempts a public clientpublic native clients are refused until F4
22amr for passkeys["pop","mfa"] when the server verified the UV flag, never hwk; no ADR-010 change herepasskey amr is ["pop","mfa"] when user verification was verified, never hwk
23Absolute refresh-family lifetimenone; the sliding REFRESH_TOKEN_LIFESPAN is unchangedwhen the realm’s sso_max_lifespan is set, it caps refresh families; unset keeps today’s sliding behaviour
24DPoP for native_backend in F3operator opt-in per client; the FiPA §9.5 deviation stays recorded for the restDPoP for native_backend is on by default from F3; an operator may switch it off per client, with a boot warning, and the FiPA §9.5 deviation is then recorded for that client
25Recovery after losing the only passkeyredirect_to_web; F2b designs the hosted policy (NIST SP 800-63B-4 §4.2.2.1 “Recovery Without Identity Proofing”)recovery codes are issued at the first passkey enrolment and redeemed on the hosted page; the FiPA endpoint keeps redirect_to_web
26Browser-origin guardon, keyed on Origin and Sec-Fetch-Site onlythe browser-origin guard applies only on the FiPA endpoint’s transport, never in the shared engine
27Dangerous scopes at the endpointredirect_to_web in every environmentdangerous scopes answer redirect_to_web
28Endpoint path/oauth/authorize-challengethe path is /oauth/authorize-challenge
29Known-IP exemption from the per-account waiton; the attempt still counts toward the ceilingthe known-IP exemption is on; the attempt still counts
30Account with no addressF1 registers password accounts only; F2b designs registration by passkey alone, proved by the registration ceremonypasskey-only accounts (no address) are in 1.0; their registration ceremony proves them; recovery codes are mandatory for them

On 2026-10-11 the maintainer chose the provider that question 15 had left open. The reference ceremony app ships a same-origin proof-of-work challenge, and an operator may add their own hosted provider, whose script then runs on the hosted sign-up and sign-in pages. A required challenge needs JavaScript there. Decision 7 and ADR-019 Decision 5 hold the rules.

Decisions parked for the maintainer

No question is parked. The maintainer decided questions 1–30 on 2026-10-08 and 2026-10-09 (see “Decided by the maintainer” above).