fix: never cache the login flow (stale pre-auth responses in Safari) #12

Merged
andrew merged 1 commits from fix/login-flow-cache-control into main 2026-09-14 21:57:00 -04:00
Member

Why

After the Symfony 7.4 → 8.1 upgrade, Safari showed a strange login bug: log in successfully, refresh, and you land back on the login page — as if the session was lost. Navigate to any other URL instead, and the session works fine. Log in a second time and you get shown the first login's session.

Root cause: the login flow was cacheable. Symfony's default Cache-Control: no-cache, private still permits a browser to store a response — it only requires revalidation before reuse. On some paths Safari reuses those stored responses without a clean round-trip, replaying a stale pre-auth response (login page, redirect, or first-session payload) on refresh. The app-side upgrade is fine — it's the cache semantics of the login chain that needed tightening.

What changed

Server — SecurityHeadersListener

Non-2xx responses now send a strict anti-caching header set:

Cache-Control: no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0
Pragma: no-cache
Expires: 0
Surrogate-Control: no-store
Vary: *

This covers the login page (401), failed logins (401), redirects (303), rate-limit pages (418/429), and error pages — everything the browser can see during the login flow. Because it runs on kernel.response, it's applied regardless of which listener produced the response.

2xx responses are deliberately excluded (! $response->isSuccessful()): the 2xx "grant" responses (already-authenticated / public access) are consumed by Caddy's forward_auth check and never reach the browser — and if they did, they must not contaminate a protected service's own caching.

Frontend — templates/_script.html.twig

  • fetch(..., { cache: 'no-store' }) — the login submission never reads from or writes to the HTTP cache.
  • location.replace(url) instead of location.href = url when following a redirect — keeps the login page out of history and the back-forward cache, so a bfcache restore can't resurrect a stale copy.

Edge — docs/Caddyfile

Added a reusable (preauth_no_store) snippet and imported it into every forward_auth example:

(preauth_no_store) {
	header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0"
	header_down Pragma "no-cache"
	header_down Expires "0"
	header_down Surrogate-Control "no-store"
	header_down Vary "*"
}

This makes the guarantee at the edge and replaces any conflicting values the auth upstream might send. (The readme example shows the same lines inline for copy-paste.)

Why header_down, and why only non-2xx?

Verified against Caddy's source and a live Caddy 2.11.4 lab replicating this app:

  • 2xx auth responses are intercepted by forward_auth: Caddy copies only the copy_headers fields onto the original request and handling continues to the service. The auth response itself never reaches the browser. A catch-all guard would only be dead weight there — and worse, if it leaked onto the service's response it would break legitimate caching.
  • Non-2xx auth responses are copied straight back to the client — these are exactly the login page / redirects / rate-limit responses we need to protect.
  • header_down inside forward_auth replaces headers on exactly that non-2xx path (adds them when missing, overwrites conflicting values like a stray public, max-age=100), and does not touch the protected service's own responses — the service's Cache-Control passes through untouched.
  • A handle_response-based guard was tested and rejected: it duplicated header values and could leave the upstream's conflicting value alongside.

Tests

  • tests/Unit/Listener/SecurityHeadersListenerTest.php — 10 cases: header set on 401/303/418/500; not present on 200; service cache headers preserved; sub-requests skipped; existing security headers + CSP behavior unchanged.
  • tests/Functional/CacheControlFlowTest.php — full-kernel coverage: login page, failed login, successful-login 303, rate-limit response all non-cacheable; authenticated 200 and public-access 200 left clean; and the rendered login page carries the cache: 'no-store' / location.replace JS changes.

Validated: 313 tests, 738 assertions green, php-cs-fixer clean, updated docs/Caddyfile validates with caddy validate, and an end-to-end run of the real app behind a real Caddy exercised the full login handshake (login page → failure → success → replay → authenticated access).

Notes

  • no-store (not just no-cache) is the operative directive — per spec it forbids storing the response at all, which is what we need.
  • The Caddyfile snippet is optional belt-and-braces: preauth sets these headers itself, but the edge guard removes any dependence on upstream correctness.
  • No behavior change for normal authenticated traffic: the protected service's cache headers are never modified by preauth or by the guard.
## Why After the Symfony 7.4 → 8.1 upgrade, Safari showed a strange login bug: log in successfully, refresh, and you land back on the login page — as if the session was lost. Navigate to any other URL instead, and the session works fine. Log in a second time and you get shown the first login's session. Root cause: **the login flow was cacheable**. Symfony's default `Cache-Control: no-cache, private` still *permits* a browser to store a response — it only requires revalidation before reuse. On some paths Safari reuses those stored responses without a clean round-trip, replaying a stale pre-auth response (login page, redirect, or first-session payload) on refresh. The app-side upgrade is fine — it's the cache semantics of the login chain that needed tightening. ## What changed ### Server — `SecurityHeadersListener` Non-2xx responses now send a strict anti-caching header set: ``` Cache-Control: no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0 Pragma: no-cache Expires: 0 Surrogate-Control: no-store Vary: * ``` This covers the login page (401), failed logins (401), redirects (303), rate-limit pages (418/429), and error pages — everything the browser can see during the login flow. Because it runs on `kernel.response`, it's applied regardless of which listener produced the response. **2xx responses are deliberately excluded** (`! $response->isSuccessful()`): the 2xx "grant" responses (already-authenticated / public access) are consumed by Caddy's `forward_auth` check and never reach the browser — and if they did, they must not contaminate a protected service's own caching. ### Frontend — `templates/_script.html.twig` - `fetch(..., { cache: 'no-store' })` — the login submission never reads from or writes to the HTTP cache. - `location.replace(url)` instead of `location.href = url` when following a redirect — keeps the login page out of history and the back-forward cache, so a bfcache restore can't resurrect a stale copy. ### Edge — `docs/Caddyfile` Added a reusable `(preauth_no_store)` snippet and imported it into every `forward_auth` example: ```caddyfile (preauth_no_store) { header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0" header_down Pragma "no-cache" header_down Expires "0" header_down Surrogate-Control "no-store" header_down Vary "*" } ``` This makes the guarantee at the edge and *replaces* any conflicting values the auth upstream might send. (The readme example shows the same lines inline for copy-paste.) ## Why `header_down`, and why only non-2xx? Verified against Caddy's source and a live Caddy 2.11.4 lab replicating this app: - **2xx auth responses are intercepted by `forward_auth`**: Caddy copies only the `copy_headers` fields onto the original request and handling continues to the service. The auth response itself *never reaches the browser*. A catch-all guard would only be dead weight there — and worse, if it leaked onto the service's response it would break legitimate caching. - **Non-2xx auth responses are copied straight back to the client** — these are exactly the login page / redirects / rate-limit responses we need to protect. - **`header_down` inside `forward_auth` replaces headers on exactly that non-2xx path** (adds them when missing, overwrites conflicting values like a stray `public, max-age=100`), and does **not** touch the protected service's own responses — the service's `Cache-Control` passes through untouched. - A `handle_response`-based guard was tested and rejected: it duplicated header values and could leave the upstream's conflicting value alongside. ## Tests - `tests/Unit/Listener/SecurityHeadersListenerTest.php` — 10 cases: header set on 401/303/418/500; *not* present on 200; service cache headers preserved; sub-requests skipped; existing security headers + CSP behavior unchanged. - `tests/Functional/CacheControlFlowTest.php` — full-kernel coverage: login page, failed login, successful-login 303, rate-limit response all non-cacheable; authenticated 200 and public-access 200 left clean; and the rendered login page carries the `cache: 'no-store'` / `location.replace` JS changes. Validated: `313 tests, 738 assertions` green, `php-cs-fixer` clean, updated `docs/Caddyfile` validates with `caddy validate`, and an end-to-end run of the real app behind a real Caddy exercised the full login handshake (login page → failure → success → replay → authenticated access). ## Notes - `no-store` (not just `no-cache`) is the operative directive — per spec it forbids storing the response at all, which is what we need. - The Caddyfile snippet is optional belt-and-braces: preauth sets these headers itself, but the edge guard removes any dependence on upstream correctness. - No behavior change for normal authenticated traffic: the protected service's cache headers are never modified by preauth or by the guard.
lyra added 1 commit 2026-09-14 19:32:35 -04:00
The login page, failed logins, redirects, and rate-limit/error pages could
be stored by the browser (Symfony's default 'no-cache, private' still
permits storage — it only requires revalidation). Older Safari builds may
then replay a stale pre-auth response on refresh, appearing to log the
user back out, or show a previous session after logging in again.

- SecurityHeadersListener: send strict anti-caching headers on non-2xx
  responses only (no-store/no-cache/must-revalidate/proxy-revalidate,
  max-age=0, s-maxage=0 + Pragma, Expires, Surrogate-Control, Vary: *).
  2xx grants (already-authenticated / public access) are consumed by
  Caddy's forward_auth check and never reach the browser, and protected
  services' own cache headers must stay untouched.
- templates/_script.html.twig: fetch() with cache: 'no-store'; follow
  redirects with location.replace() to keep the login page out of history
  and the back-forward cache.
- docs/Caddyfile: reusable (preauth_no_store) snippet imported into every
  forward_auth block, using header_down so the guarantee holds at the edge
  (verified: replaces conflicting upstream values, leaves service
  responses alone).
- tests: unit coverage for the listener and functional coverage for the
  full HTTP kernel (login/failure/redirect/rate-limit not cacheable;
  200 grants untouched); asserts the rendered page carries the JS changes.
- readme/CHANGELOG updates.
andrew approved these changes 2026-09-14 21:56:53 -04:00
andrew merged commit 054b8ef48f into main 2026-09-14 21:57:00 -04:00
andrew deleted branch fix/login-flow-cache-control 2026-09-14 21:57:00 -04:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: public/preauth#12