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:
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:
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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, privatestill 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 —
SecurityHeadersListenerNon-2xx responses now send a strict anti-caching header set:
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'sforward_authcheck and never reach the browser — and if they did, they must not contaminate a protected service's own caching.Frontend —
templates/_script.html.twigfetch(..., { cache: 'no-store' })— the login submission never reads from or writes to the HTTP cache.location.replace(url)instead oflocation.href = urlwhen 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/CaddyfileAdded a reusable
(preauth_no_store)snippet and imported it into everyforward_authexample: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:
forward_auth: Caddy copies only thecopy_headersfields 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.header_downinsideforward_authreplaces headers on exactly that non-2xx path (adds them when missing, overwrites conflicting values like a straypublic, max-age=100), and does not touch the protected service's own responses — the service'sCache-Controlpasses through untouched.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 thecache: 'no-store'/location.replaceJS changes.Validated:
313 tests, 738 assertionsgreen,php-cs-fixerclean, updateddocs/Caddyfilevalidates withcaddy 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 justno-cache) is the operative directive — per spec it forbids storing the response at all, which is what we need.