Priority 70 sits after RejectListener (77) and before LoginListener (66), and
both bounds are load-bearing:
- After 77 so a rate-limited IP never reaches a ceremony. Passkeys cannot be
used to sidestep a lockout (D3), which is the point of the reviewer's third
clarification.
- Before 66 because LoginListener treats any POST to the auth subdomain as a
login attempt. A ceremony finish body has no username/totp, so Payload::load()
returns null and the request would be scored as a failed login, burning a
rate-limit token for every legitimate passkey login.
Verified in the live container rather than assumed: debug:event-dispatcher
confirms 77 -> 70 -> 66.
Other properties asserted by tests: every header-bearing request gets a JSON
response so fetch() callers never receive HTML; registration identity comes from
the live session, never the request body; a failed ceremony is indistinguishable
from a wrong TOTP code and spends the same shared budget; and begin is bounded
by a separate resource guard that deliberately does not consume failure budget.
The listener also marks its responses so SecurityHeadersListener can apply
no-store: these are the only browser-facing 2xx this application produces, since
the auth subdomain has no forward_auth in front of it. CSP gains
publickey-credentials-get/-create only when passkeys are available, so the
unavailable case stays byte-identical to before.