Document passkey authentication

Covers the README (prerequisites, the two hard requirements, every new env var,
how registration and login work, and the counter caveat), SECURITY.md (the
ceremony model, single-use challenges, origin handling, the shared rate-limit
budget, and the attestation rationale with the conditions that would reverse
it), CHANGELOG (Added/Security/Changed), ROADMAP (Phase 2c complete, with the
deviations from the original sketch) and DESIGN_CONSIDERATIONS (the four
decisions whose reasoning is not visible in the code).

The docs lead with the two prerequisites because both are enforced rather than
advisory: enabling passkeys without central auth, or on a host that cannot
serve HTTPS, fails at container start. Neither is a runtime surprise, and a
reader needs to know that before they turn the feature on.

The plan document is updated to record that it is complete, and to note the two
places where implementation deviated from it — the listener/extraction order,
and register-begin not being a listener operation. The second was a design
error in the plan, not just an ordering change, so it is called out explicitly.
This commit is contained in:
2026-09-27 11:37:02 +00:00
parent ffe6870231
commit 6bbfd44e7d
6 changed files with 256 additions and 34 deletions
+33
View File
@@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] — v1.1
### Added
- **Passkey authentication (WebAuthn)** — Registered passkeys can replace the
TOTP code for everyday logins. Registration itself still requires a valid
code, so a passkey can never be created without already holding the secret.
- New dependency: `web-auth/webauthn-lib` `^5.3` (resolved to 5.3.9);
`composer audit` reports no advisories.
- **Requires central auth** (`SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`) so
there is one relying party for the whole domain, and **requires HTTPS in
every environment, development included**. Enabling it in a configuration
that cannot work fails at container start rather than in a browser.
- Registration is a checkbox on the login form; login is a button. Passive
keys and OS pickers work normally, with the code as a fallback.
- New `PasskeyListener` (priority 70) — after the rate-limit gate so a
blocked IP never reaches a ceremony, and before `LoginListener` so a
ceremony request is not misfiled as a failed login.
- New env vars: `PASSKEY_ENABLED`, `PASSKEY_RP_NAME`,
`PASSKEY_USER_VERIFICATION`, `PASSKEY_TIMEOUT`, `PASSKEY_BUTTON_NAME`,
`PASSKEY_REGISTER_NAME`, `PASSKEY_BEGIN_BURST_COUNT`,
`PASSKEY_BEGIN_BURST_TIME`.
- New `docs/examples/Caddyfile` section describing local development over
real TLS, because there is deliberately no `http://` exemption.
- **Public rate-limited access** — Select paths can now be made publicly
accessible without TOTP authentication, with separate per-IP rate limiting.
This is useful for exposing public content (e.g., public Gitea repositories)
@@ -25,7 +45,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New `PublicPathMatcher` service for path pattern matching.
- New `PublicAccessListener` (priority 84) in the request pipeline.
### Security
- **Passkey ceremonies** — The challenge is issued and stored server-side and
the client's copy is never trusted; it is single-use, deleted before
verification so a failed or replayed attempt cannot be retried against the
same challenge. Only the derived `https://{AUTH_SUBDOMAIN}` origin is ever
accepted, and an unknown credential produces the same response as a wrong
code so the endpoint cannot be used for enumeration.
### Changed
- **Session issuing extracted into `SessionIssuer`** — `LoginManager`
previously built the session cookie itself. Both login paths now share one
implementation, so a passkey login and a code login set an identical cookie;
two copies would have drifted, most likely in cookie attributes, where the
difference is invisible until it breaks in a browser.
- **Upgraded Symfony 7.4 → 8.1** — All `symfony/*` components bumped to
`8.1.*` (resolved to 8.1.2–8.1.6). The 7.4 deprecation sweep was clean
(test suite runs with `failOnDeprecation`), so the major-version jump
+54
View File
@@ -8,6 +8,60 @@ This document was originally prepared as a design review. Items that have been a
---
## 0. Passkey implementation notes
Four decisions are worth recording because the reasoning is not obvious from the
code, and each looks like an odd choice without it.
### 0.1 The signature counter is checked leniently, against the library's default
`webauthn-lib`'s default checker requires a *strictly increasing* counter. That
is wrong for the passkeys this feature targets: a synchronised passkey reports a
constant `0` forever, so the default rejects a brand-new credential on its
**first** login. Measured against the installed version: stored `0`, reported
`0` → `CounterException`.
The failure mode is what makes this worth a note. It cannot happen in a unit
test that increments the counter — only on real hardware, and only for the most
common kind of passkey. `PasskeyCounterChecker` therefore accepts equal-or-greater
and rejects only a counter that moves *backwards*. Clone detection is explicitly
not claimed as a property of this feature.
### 0.2 The ceremony replies are the only browser-facing 2xx
`SecurityHeadersListener` applies `no-store` to non-2xx responses only, on the
assumption that a 2xx is consumed by the proxy's `forward_auth` check. That
assumption is false for a ceremony reply: the auth subdomain is `reverse_proxy`-ed
with no `forward_auth` in front of it, so the JSON goes straight to the browser.
Left alone it would be cacheable, and a browser could replay a stale challenge.
The producer marks the response (`PasskeyListener` or `LoginManager`) and the
caching policy lives in one place that consumes the marker, rather than being
duplicated at each site that happens to return 2xx.
### 0.3 Registration is a checkbox on the login form, not an endpoint
A separate `register-begin` endpoint was the first design and would have been a
vulnerability: it hands out a challenge without proving anything. The TOTP check
is what authorises registration, and that check happens inside `LoginManager` as
part of an ordinary login submission — so `LoginManager` is where the ceremony
starts.
There is no session cookie to check at that point either, which makes the point
neatly: the whole flow is what *produces* the session, so anything gated on one
cannot be part of it. The capability at `register-finish` is the single-use
ceremony id, issued server-side and bound to the identity that passed the check.
### 0.4 Both login paths share one session-issuing implementation
`SessionIssuer` was extracted from `LoginManager` when the passkey ceremony
needed the same behaviour. Two implementations would have drifted, and the most
likely place to drift is cookie attributes — where a difference is invisible
until it breaks in a browser, on one path only. A functional test compares the
cookies the two paths produce, field by field.
---
## 1. Security
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
+26 -31
View File
@@ -295,6 +295,9 @@ the management surface is incomplete.
### Phase 2c — Passkey Authentication
**Status: complete.** See `docs/passkey-auth-subdomain-plan.md` for the full
plan, the evidence behind each decision, and the deviations noted below.
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
authentication method alongside TOTP and backup codes.
@@ -305,39 +308,31 @@ codes. For a pre-auth gate that friends and family use, passkeys would
be a major UX improvement — especially for non-technical users who
struggle with TOTP apps.
**Design considerations:**
**What was built**, and how it differs from the sketch above:
- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one
secret shared with all devices), each device registers its own
passkey. This is actually better for a family-use gate — you can
register mom's phone separately from dad's laptop.
- **A Symfony bundle was not used**, only `web-auth/webauthn-lib`. The bundle
brings a database-backed credential repository and a controller setup that do
not fit a no-database, listener-only application; the library alone is a
clean fit and its types are confined to `PasskeyManager` and
`PasskeyCeremonyFactory` so a major-version rename touches two files.
- **Registration happens in the browser, not a console command.** The checkbox
on the login form is authorised by the TOTP code in the same submission, so
it needs no separate token and no CLI. This also settles the "how does the
identity get specified" question: it is the identity that just authenticated.
- **Central auth is a hard prerequisite.** A passkey is scoped to one relying
party, so passkeys require `SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`; the RP ID
is always that base domain. Without it the feature stays off, rather than
quietly scoping credentials to a single host.
- **HTTPS is required with no exemption**, development included, since an
`http://` escape hatch is how the same weakness reaches production.
- **Failed attempts share the TOTP rate-limit budget**, so passkeys cannot be
used to sidestep a lockout.
- **Attestation is `none`**, measured rather than assumed — see SECURITY.md and
plan §2.3.
- WebAuthn requires a **challenge-response flow**:
1. Client requests a challenge (preauth generates and stores a
challenge nonce, similar to the existing nonce system)
2. Browser prompts for biometric/PIN, creates a signed assertion
3. Server verifies the assertion against the registered credential
- This is a **two-step flow** unlike TOTP's single-step, which means
the login page JS and `LoginListener` need to handle an additional
round-trip. The existing nonce + AJAX pattern in `_script.html.twig`
is a good foundation — extend it with a "use passkey" button that
initiates the `navigator.credentials.get()` flow.
- Library: `web-auth/webauthn-framework` (PHP WebAuthn library,
Symfony bundle available). Would add registration ceremony (console
command or initial-setup flow to register a passkey).
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
8.1 and FrankenPHP
- [ ] Design passkey registration flow (console command? first-visit
setup? separate registration endpoint?)
- [ ] Implement challenge generation and storage (extend existing
nonce/cache infrastructure)
- [ ] Implement assertion verification in a new `PasskeyManager`
service (implements a shared `AuthMethodInterface`?)
- [ ] Add passkey option to login page JS (`navigator.credentials.get()`)
- [ ] Handle multiple registered passkeys (per-device)
**Remaining work:** none for the feature itself. Discoverable-credential
(usernameless) login is possible but not needed, since the login page already
lists registered credentials.
- [ ] Console command: `app:list-passkeys` — show registered devices
- [ ] Console command: `app:remove-passkey` — revoke a passkey
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
+53
View File
@@ -51,6 +51,59 @@ calls it per request to decide whether a request may reach the upstream service.
secrets belong in `.env.local` or `bin/console secrets:set`, read via
`%env(...)%`. `.env.example` and `.env.test` are the committed env files.
### Passkeys (WebAuthn)
Off by default (`PASSKEY_ENABLED=0`). When on, the following hold:
- **Registration requires a valid TOTP code.** The checkbox rides on an
ordinary login submission, and the code check in that same request is what
authorises the ceremony. There is no enrolment token, no CLI path, and no way
to create a credential without already holding the secret. The identity comes
from the authenticated session, never from the request body.
- **The challenge is server-authoritative and single-use.** It is generated and
stored server-side; the client's copy is never trusted. The stored record is
deleted *before* verification runs, so a failed or replayed attempt cannot be
retried against the same challenge. Records live in the in-memory `nonceCache`
with a 300-second TTL and deliberately do not survive a restart.
- **Only the derived origin is accepted.** The allowed origin is always
`https://{AUTH_SUBDOMAIN}`, computed from configuration and never from the
request. `http://` is therefore rejected regardless of how the request
arrived, and there is no setting that re-enables it. The library's deprecated
`setSecuredRelyingPartyId()` escape hatch is not used, and development uses
real TLS instead of an exemption.
- **The RP ID is the base domain**, so a credential is scoped to every service
on that domain. This is the intended behaviour and the reason central auth is
a hard prerequisite: without a single shared domain there is no sane RP ID.
- **Failures are indistinguishable.** An unknown credential, a bad signature
and a wrong origin all produce the same response as a wrong TOTP code, so the
endpoint cannot be used to enumerate credentials.
- **Failures share the login rate-limit budget.** A failed ceremony costs the
same token as a wrong code, and once the limit is reached every method is
blocked. Passkeys cannot be used to sidestep a lockout, and the resource guard
that bounds ceremony *starts* is deliberately separate, so a legitimate login
never spends failure budget.
- **The signature counter is not a security control.** Most passkeys —
anything synchronised through a keychain — report a constant counter, so a
counter-based clone check would lock users out of their own credentials.
preauth accepts an unchanged counter and rejects only one that moves
*backwards*, which is the only signal the value can carry. **Clone detection is
deliberately not a property this feature claims.**
- **Attestation is deliberately not requested** (`attestation: 'none'`).
Attestation conveyance is only a preference a client may ignore, and the FIDO
metadata service is bypassed both by the zero AAGUID that privacy-preserving
passkeys already send and by self attestation — while still refusing
legitimate authenticators newer than its cached blob. This was measured rather
than assumed; see §2.3 of
`docs/passkey-auth-subdomain-plan.md` for the evidence. **Revisit if** a
deployment needs to prove which make and model of authenticator is enrolled,
or if a policy (rather than a preference) requires attested keys — in which
case the metadata service must be pinned and kept current, and the zero-AAGUID
case decided explicitly rather than by omission.
- **The ceremony replies are not cacheable.** They are the only 2xx this
application returns straight to a browser (every other 2xx is consumed by the
proxy's `forward_auth` check), so they carry the same anti-caching headers as
the rest of the login flow.
## Scope
In scope: the application code in `src/`, the shipped `Caddyfile`, the
+22
View File
@@ -652,6 +652,10 @@ the `doctrine/deprecations` triggers).
## 8. Implementation order
**Status: complete.** All steps below are implemented and on
`feat/passkey-auth-subdomain`. Two deviations from the order as written, both
noted inline.
Each step is independently committable and leaves the suite green.
1. **Dependency** *(done on the spike branch)* — `composer require
@@ -667,11 +671,29 @@ Each step is independently committable and leaves the suite green.
5. **`PasskeyListener`** — priority 70, header dispatch, always terminate,
no-store marker. Unit-test every branch incl. "post-shaped request must not
reach `LoginListener`".
> **Deviation 1 — done after step 6.** The listener needs the shared session
> issuing that step 6 extracts, so the order had to be inverted.
>
> **Deviation 2 — three operations, not four.** `register-begin` is not a
> listener operation, and the first implementation was wrong to make it one.
> It would have handed out a challenge without proving anything; there is
> also no session cookie to check at that point, since the whole flow is what
> produces the session. The ceremony is started by `LoginManager`, after it
> verifies the code and nonce. A test pins that the listener refuses the
> operation.
6. **Extract session issuing** from `LoginManager` so both paths share it —
prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest`
before touching anything else (Q3.8).
> Verified as intended: all 18 existing `LoginManagerTest` cases passed
> unchanged, and a functional test now compares the two paths' cookies
> field by field.
7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent
hand-off described in §3.1, `_passkey_register.html.twig`.
> Note: the separate script template was not needed — both handlers share
> helpers, so `_passkey.html.twig` holds them and `login.html.twig` stays a
> single readable file. The checkbox is also **not** rendered where the form
> does not POST, since registration authorises itself with the code carried
> in that submission.
8. **Login UI + CSP** — `_passkey.html.twig`, `SecurityHeadersListener`, extend
`CacheControlFlowTest` and `SecurityHeadersListenerTest`.
9. **Functional tests** with real crypto (§7.2).
+68 -3
View File
@@ -152,6 +152,56 @@ Rate limiting **cannot be disabled**. It uses a compound sliding window:
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
### Passkey Authentication
Passkeys (WebAuthn) can replace the TOTP code for everyday logins, while the
code remains the way a new device is enrolled.
**Two prerequisites, both enforced.** The feature refuses to operate without
them rather than degrading quietly:
1. **Central auth must be configured** (`SUBDOMAIN_REDIRECT=true` and a real
`AUTH_SUBDOMAIN`). A passkey is scoped to one relying party, so there has to
be a single shared domain for the whole family of services. Without it,
passkeys are switched off — they would otherwise be scoped to a single host
and confuse users with multiple, unrelated passkeys.
2. **HTTPS is required, in development too.** There is no `http://localhost`
exemption and no setting that re-enables one, because such an exemption is
exactly how the same weakness ends up enabled in production. To exercise
passkeys locally, see the TLS note in `docs/examples/Caddyfile`.
`localhost` therefore cannot be used for passkeys: it has no base domain, so
central auth cannot be configured at all.
| Variable | Default | Description |
|----------|---------|-------------|
| `PASSKEY_ENABLED` | `0` | Master switch. Enabling it without the prerequisites above makes the container fail at startup, rather than offering a feature that cannot work. |
| `PASSKEY_RP_NAME` | `TITLE` | Name shown in the authenticator prompt. |
| `PASSKEY_USER_VERIFICATION` | `required` | `required`, `preferred`, or `discouraged`. An unrecognised value falls back to `required`, never to something weaker. |
| `PASSKEY_TIMEOUT` | `60000` | Ceremony timeout in milliseconds. |
| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | Label for the sign-in button. |
| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | Label for the registration checkbox. |
| `PASSKEY_BEGIN_BURST_COUNT` | `30` | Ceremonies one caller may start per window. A resource guard, not part of the login budget. |
| `PASSKEY_BEGIN_BURST_TIME` | `60` | Window for the above, in seconds. |
**Registering a device.** Log in with your code as usual, tick *Register this
device as a passkey*, and approve the prompt. The TOTP check in that same
submission is what authorises the registration — there is no separate enrolment
token and no CLI command, so a passkey cannot be created without already holding
a valid code.
**Logging in.** Once registered, *Sign in with a passkey* signs you in with a
fingerprint, face, or device PIN instead of typing a code. Failed passkey
attempts count against the same rate-limit budget as wrong codes, so passkeys
cannot be used to sidestep a lockout, and after the limit is reached *every*
method is blocked equally.
> **On signature counters.** Many passkeys (anything synchronised through a
> keychain) report a constant counter, so a counter-based clone check would lock
> users out of their own credentials. Preauth accepts an unchanged counter and
> rejects only one that moves *backwards*. Clone detection is deliberately not a
> property this feature claims — see `SECURITY.md`.
### Public Rate-Limited Access
Preauth can provide rate-limited unauthenticated access to select public
@@ -235,9 +285,17 @@ passes through a priority-ordered chain of listeners:
3. **PublicAccessListener** (priority 84) — If public paths are configured,
allows rate-limited unauthenticated access to matching paths.
4. **RejectListener** (priority 77) — Rate-limiting gate.
5. **LoginListener** (priority 66) — Processes login attempts.
6. **InterceptListener** (priority 55) — Renders login page or redirects.
7. **SecurityHeadersListener** (response) — Adds security headers.
5. **PasskeyListener** (priority 70) — WebAuthn ceremonies, when enabled.
6. **LoginListener** (priority 66) — Processes login attempts.
7. **InterceptListener** (priority 55) — Renders login page or redirects.
8. **SecurityHeadersListener** (response) — Adds security headers.
`PasskeyListener` sits deliberately between the rate-limit gate and the login
handler: **after** `RejectListener`, so a blocked IP never reaches a ceremony;
and **before** `LoginListener`, because that listener treats any POST to the auth
subdomain as a login attempt, and a ceremony request carries no code — it would
otherwise be counted as a failed login and burn rate-limit budget on every
legitimate passkey sign-in.
### Security Model
@@ -256,6 +314,13 @@ passes through a priority-ordered chain of listeners:
Successful (2xx) responses are deliberately excluded — they are
consumed by the proxy's `forward_auth` check and never reach the
browser, so a protected service's own caching is not affected.
Passkey ceremony replies are the one exception: they are the only 2xx
this application returns straight to a browser, so they carry the same
anti-caching headers.
- **Passkeys**: registerable only after a valid TOTP code; challenge is
server-issued and single-use; RP ID is always the base domain; only the
derived `https://` origin is ever accepted; a failed attempt is
indistinguishable from a wrong code and shares its rate-limit budget.
### Cache