Bright green emergency exit sign illuminated in darkness — the official escape hatch

Photo by Esdras Jaimes on Pexels

Symfony Silently Kills Your HTTP Cache When a Session Starts

I learned the hard way that Symfony has a security listener that silently overwrites your Cache-Control with private, max-age=0, must-revalidate the moment a PHP session is started during the request.

More importantly, there is an official escape hatch when you know the response is safe to cache anyway.

If you have ever fought this header:

Cache-Control: max-age=0, must-revalidate, private, s-maxage=86400

while your code clearly asked for a public response, this one is for you.

The symptom

With API Platform 3.4 and this configuration, you define cache headers for a resource:

#[ApiResource(
    cacheHeaders: [
        'etag' => true,
        'max_age' => 86400,
        'shared_max_age' => 86400,
        'vary' => ['Accept'],
    ]
)]
class Article
{
}

You expect something like:

Cache-Control: max-age=86400, public, s-maxage=86400
ETag: "abc123"

But in some situations, you get:

Cache-Control: max-age=0, must-revalidate, private, s-maxage=86400

Even though s-maxage is present in the header, it becomes useless for a shared cache: the response is now private and the shared cache will skip it entirely, ignoring the s-maxage duration you set.

No error, no log: your API Platform configuration was correct — it was simply modified later in the HTTP cycle.

How I found it: Behat tests on the cache

I didn't find this by reading source code out of curiosity on a Sunday afternoon. I found it while writing Behat tests for the HTTP cache on our API.

I was writing scenarios to cover cache behavior on our API endpoints, something like:

Scenario: Published articles are publicly cached
  Given I am authenticated as a user
  When I send a "GET" request to "/api/articles"
  Then the response status code should be 200
  And the response header "cache-control" should contain "public"
  And the response header "cache-control" should contain "max-age=86400"
  And the response header "cache-control" should contain "s-maxage=86400"

At execution, the test failed. No public in the header — just a max-age=0, must-revalidate, private, s-maxage=86400 instead.

That's when I started looking at what could modify the response after API Platform had correctly set its headers.

The culprit: AbstractSessionListener at -1000

The responsible party is Symfony\Component\HttpKernel\EventListener\AbstractSessionListener. Its job goes beyond saving the session: when a session is started during a request, Symfony transforms the response into a private, uncacheable one by default — a security measure to avoid accidentally caching and redistributing user-specific private data across a shared cache misconfiguration.

Two details explain why this catches everyone off guard.

It runs very late. In Symfony 6.4+, the listener subscribes to kernel.response at priority -1000, explicitly to execute among the very last response listeners — after API Platform, after your own subscribers that rarely have such a low priority set, I'd presume.

It checks whether the session was actually used, not just whether it exists. It's not $request->hasSession(). The real code:

if ($autoCacheControl) { // this condition will be useful later (`true` by default)

    $maxAge = $response->headers->hasCacheControlDirective('public')
        ? 0
        : (int) $response->getMaxAge();

    $response
        ->setExpires(new \DateTimeImmutable('+'.$maxAge.' seconds'))
        ->setPrivate()
        ->setMaxAge($maxAge)
        ->headers->addCacheControlDirective('must-revalidate');
}

Before it ever reaches if ($autoCacheControl), AbstractSessionListener::onKernelResponse() runs through several guard clauses — each can return early without touching Cache-Control:

  1. Main request onlyif (!$event->isMainRequest() || (!$container->has('initialized_session') && !$request->hasSession())) return; : sub-requests are ignored.
  2. Internal header popped early$autoCacheControl = !$response->headers->has(self::NO_AUTO_CACHE_CONTROL_HEADER) then unconditional remove(), even if the method returns right after.
  3. Session attached?if (!$request->hasSession(true)) return; (true also checks request attributes).
  4. If isStarted()save() + cookie handling — early save (locks, fastcgi_finish_request, ID regeneration) and Set-Cookie management: clearCookie if session empty but cookie present, setCookie if new ID and session not empty.
  5. Actually used?if ($session instanceof Session ? 0 === $session->getUsageIndex() : !$session->isStarted()) return; : a session that exists but was never read/written does not force private.

Only after these early exits does the block run — maxAge is 0 if the response was public else getMaxAge(), then setExpires(), setPrivate(), setMaxAge($maxAge) and must-revalidate.

File: src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php6.4 on GitHub.

Why Symfony does this

A session generally means user-specific data is involved. If a GET /api/me response for user A became publicly cacheable, user B could receive A's data from the shared cache (Varnish, for example).

Symfony defaults to a conservative behavior: session used → private response.

Having an Authorization: Bearer ... header does not mean Symfony started a session. The actual trigger for AbstractSessionListener is effective session usage somewhere in the request — not the presence of an auth token.

So even if your firewall is configured with stateless: true, your code — at some point — might still use the session for entirely legitimate reasons, triggering the listener.

The real question to ask yourself is: is a session actually being used during this request — via your code, a bundle, a listener, a controller?

To find out, look for the accesses that actually start the session:

$request->getSession()->start()
$request->getSession()->set(...)
$request->getSession()->get(...)
$request->getSession()->getFlashBag()

The if ($autoCacheControl) escape hatch

Remember if ($autoCacheControl) {? That's our escape route.

Because Symfony planned an escape hatch to bypass this behavior when you explicitly want to tell the framework:

OK, I know you want to protect me, but I know what I'm doing — so can you ignore this for a minute?

It goes through checking for the presence of the NO_AUTO_CACHE_CONTROL_HEADER constant in the response headers:

$autoCacheControl = !$response->headers->has(
    self::NO_AUTO_CACHE_CONTROL_HEADER
);

The constant resolves to 'Symfony-Session-NoAutoCacheControl'.

If your Response carries this header, the listener skips the forced private, then removes the header itself before sending the response to the client:

$response->headers->remove(self::NO_AUTO_CACHE_CONTROL_HEADER);

This is an internal server signal, never a header destined for the client.

Is it a hack? No — it is documented

Until Symfony 7.0, the entire AbstractSessionListener class was @internal, so PHPStan/Psalm complained when you referenced AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER.

PR #53057[HttpKernel] Move @internal from AbstractSessionListener class to its methods and properties — removed @internal from the class (kept on methods/props), backported to 6.4, making this constant officially usable. Symfony 7.1 docs now show it explicitly:

use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;

$response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true');

Source: symfony.com/doc/current/http_cache.html#http-caching-and-user-sessions

How to use it

If you control the Response:

use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;

$response->headers->set(
    AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
    'true'
);

In an API Platform application, a kernel.response subscriber scopes the behavior properly:

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
use Symfony\Component\HttpKernel\KernelEvents;

final class BypassSessionCacheSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        // AbstractSessionListener = -1000, we need to run before it
        return [
            KernelEvents::RESPONSE => ['onKernelResponse', -100],
        ];
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();

        // Scope it by path — never globally
        if (!str_starts_with($request->getPathInfo(), '/api/')) {
            return;
        }

        $event->getResponse()->headers->set(
            AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
            'true'
        );
    }
}

-100 is nothing magical: it simply has higher priority than -1000, so this subscriber runs before AbstractSessionListener.

The pitfall to absolutely avoid

Do not set this header globally on every safe GET response:

// Do not do this without thinking about the response content
if ($request->isMethodSafe()) {
    $response->headers->set(
        AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
        'true'
    );
}

GET /api/me, GET /api/cart, or GET /api/orders must not become publicly cacheable simply because they use GET.

The right question: is this response identical for multiple users?

In my case, it was. But if it isn't, do not bypass this Symfony safety mechanism.

Also, prefer whitelisting specific paths that are allowed to bypass this protection, rather than applying it broadly.

TL;DR

#[ApiResource(cacheHeaders: [...])]
        → API Platform generates a cacheable response
        → kernel.response
        → AbstractSessionListener (-1000)
        → session used? yes
        → Cache-Control becomes private

Symfony does this intentionally to prevent a session-bound response from accidentally ending up in a shared cache.

If you know with certainty the response can be cached despite session usage:

$response->headers->set(
    AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
    'true'
);

set on the Response, never the Request, then automatically removed by the listener before sending.

And if one lesson is to remember beyond the header itself: test your cache headers like any other observable behavior of your API.


Comments