Europe/Paris
Posts

PHP 8.6 hardens session defaults: what actually breaks, and how an old php.ini cancels it all

November 2, 2026 · 6 min de lecture
PHP 8.6, due as a stable release on November 19, 2026, changes three defaults in the session extension. The question that matters before upgrading: will my application notice, and in which direction? Jorg Sowa's "Secure Session Configuration Defaults" RFC passed without a single vote against, with one separate vote per setting:
  • session.use_strict_mode goes from 0 to 1. PHP rejects a session ID that matches no existing session in storage and issues a new one. This is the defense against session fixation (the attacker plants a session ID they know on the victim, then waits for them to log in).
  • session.cookie_httponly goes from 0 to 1. JavaScript on the page can no longer read the session cookie through document.cookie. The browser still sends it with requests.
  • session.cookie_samesite goes from empty to Lax. The browser no longer sends the cookie on requests coming from another site, except top-level navigations with a safe method (clicking a link, a GET redirect).
As I write this, PHP 8.6 is at RC4 (released October 21): not for production, but plenty to test against. RC5, announced as the last one, is scheduled for November 5. As Eric L. Barnes points out on PHP News, these defaults only apply if the application, the framework and php.ini don't already set them. The usual order: the value compiled into PHP, then php.ini and the conf.d files, then ini_set() at runtime (not forgetting php_admin_value in the FPM pool, which ini_set() cannot override). The catch is in the files shipped with PHP. PHP 8.5's php.ini-production explicitly sets the old values:
Ini
; php.ini-production, PHP-8.5 branch
session.use_strict_mode = 0
session.cookie_httponly =
session.cookie_samesite =
PHP 8.6's version has been aligned with the RFC:
Ini
; php.ini-production, PHP-8.6 branch
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_samesite = "Lax"
In practice: if your deployment reuses the old php.ini (an Ansible template, a COPY php.ini in a Dockerfile, a file copied from server to server), you move to 8.6 and gain nothing, because the explicit session.use_strict_mode = 0 line overrides the new default. The other way around, the official php Docker image enables no php.ini until you copy one of the two templates it ships: with no configuration, the compiled defaults apply, so the new ones. To stop depending on luck, I set the values in a dedicated file, versioned with the project:
Ini
; docker/php/conf.d/zz-session.ini
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_samesite = "Lax"
; only if all traffic is HTTPS
session.cookie_secure = 1
Dockerfile
FROM php:8.5-fpm
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/conf.d/zz-session.ini "$PHP_INI_DIR/conf.d/zz-session.ini"
The zz- prefix makes it load after the other conf.d files, which are read in alphabetical order. It's also Marco Orta's advice: set these three values now, on PHP 8.5, so you find the regressions when you choose to rather than on upgrade day. Paul Redmond notes on Laravel News that Laravel manages its own session cookies and most applications won't see a thing. On the Symfony side, the source code gives the same answer:
  • NativeSessionStorage forces use_strict_mode to 1 in its constructor, whatever php.ini says.
  • FrameworkBundle declares cookie_httponly: true, cookie_samesite: lax and cookie_secure: auto as the defaults of framework.session, and applies them with ini_set() when the session storage is created.
An application that goes through Symfony's session service already behaves this way today. Two cases escape the rule: a framework.yaml that explicitly sets cookie_samesite: null or cookie_httponly: false, and legacy code that calls session_start() directly, outside Symfony (old scripts under public/, a bridge to a historical application). To see the configuration actually loaded:
Bash
php bin/console debug:config framework session
The risk is different for each setting:
  • SameSite=Lax: a POST coming from another site arrives without the session cookie. Typical cases are a payment provider returning the user with a POST, SP-initiated SAML SSO (mentioned by the RFC) and forms submitted from another domain. No error shows up; the user is just logged out. As Marco Orta puts it: "They fail silently".
  • HttpOnly: any script that reads the session ID from document.cookie gets a string without it. The RFC recommends a separate, non-HttpOnly CSRF token for whatever the front end needs to read.
  • Strict mode: an ID supplied from outside (handing a session over between subdomains, for example) is rejected. PHP 8.6's UPGRADING file states that a custom session handler must then implement validateId() and create_sid(), or explicitly set the option back to 0. Passing session_set_save_handler() an object without those two methods is also deprecated in 8.6.
For the payment return, the clean fix isn't going back to SameSite=None: it's to stop depending on the session in the POST callback. The payment status comes through the provider's server-to-server notification, and the browser POST only redirects with a GET, where the Lax cookie is sent again:
Php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Payment\PaymentReturnVerifier;
use App\Repository\OrderRepository;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

final class PaymentReturnController
{
    public function __construct(
        private readonly PaymentReturnVerifier $verifier,
        private readonly OrderRepository $orders,
        private readonly UrlGeneratorInterface $urls,
    ) {
    }

    #[Route('/payment/return', name: 'payment_return', methods: ['POST'])]
    public function __invoke(Request $request): RedirectResponse
    {
        // Cross-site POST: with SameSite=Lax, no session cookie here.
        // So this action never touches the session.
        if (!$this->verifier->isValid($request)) {
            throw new AccessDeniedHttpException('Invalid payment return signature.');
        }

        $order = $this->orders->getByReference($request->request->getString('reference'));

        // 303: the browser follows with a GET, a top-level navigation,
        // so the session cookie is sent again.
        return new RedirectResponse(
            $this->urls->generate('order_confirmation', ['reference' => $order->getReference()]),
            Response::HTTP_SEE_OTHER,
        );
    }
}
PaymentReturnVerifier wraps the signature check specific to your provider. The confirmation page reads the session as usual. Three commands are enough to know where you stand:
Bash
# Compiled defaults, without any php.ini
php -n -r 'foreach (["use_strict_mode", "cookie_httponly", "cookie_samesite"] as $k) { echo $k, "=", ini_get("session.$k"), PHP_EOL; }'

# Effective CLI values (often a different php.ini than FPM)
php -i | grep -E 'session\.(use_strict_mode|cookie_httponly|cookie_samesite)'

# What the browser actually receives, on a page that starts the session
curl -sS -o /dev/null -D - https://staging.example.com/login | grep -i '^set-cookie'
The first one assumes the session extension is built in, as it is in the official Docker image. Only the third one is authoritative for web traffic: the CLI and PHP-FPM often read different php.ini files. The expected line contains HttpOnly and SameSite=Lax. If it doesn't, something is still overriding the defaults.
  • Keeping the old php.ini and thinking you're covered. The explicit lines in 8.5's php.ini-production cancel the new defaults without any message.
  • Switching everything to SameSite=None for a single callback. It reopens cookie sending on every cross-site request, and None requires session.cookie_secure = 1. Fix the flow that needs it instead.
  • Starting a session in the POST callback. With no cookie received, PHP creates a new session and sends back a new cookie, which can replace the user's own and log them out when they return from payment.
On a standard Symfony application, the work often comes down to running debug:config and hunting for the session_start() calls left in legacy code (see also migrating a legacy Symfony project from PHP 7.4 to 8.1): that's where the new defaults will actually apply.
On this page
Book a call