Сессии в PHP: частые ошибки и как их не допустить
Почему сессии слетают после логина, теряются на поддоменах и портят производительность. Разбор типичных ошибок работы с сессиями в PHP 8.x и рабочие способы их обойти.

Пользователь логинится, попадает на главную и снова видит форму входа. Или наоборот: два человека в офисе за одним внешним IP получают чужие корзины. Или страница висит по три секунды на каждом запросе, потому что запросы к одному пользователю выстраиваются в очередь. Всё это — типичные симптомы неправильной работы с сессиями в PHP. Ниже — самые частые ошибки на PHP 8.x, признаки, по которым их узнают, и что делать. Инвалидация JWT и вынос сессий в отдельный сервис — отдельные темы, здесь только встроенный механизм сессий PHP.
session_start() после вывода
Классика, которая ловит новичков в первый день. Сессии инициируются через заголовок Set-Cookie, а заголовки нельзя отправить после того, как в тело ответа ушёл хотя бы один байт. Стоит закрывающему ?> в подключаемом файле оставить пробел или перевод строки перед session_start() — и вы получаете:
Warning: session_start(): Cannot start session when headers already sent
Сессия при этом не работает: cookie с идентификатором не устанавливается, при каждом запросе PHP создаёт новую сессию. Пользователь логинится, но на следующей странице он уже не аутентифицирован.
- Вызывайте
session_start()в самом начале точки входа, до любогоecho, HTML и подключения шаблонов. - Уберите закрывающий
?>из чисто-PHP файлов — в PSR-совместимом коде его не ставят как раз ради этого. - Проверьте BOM в начале файла: невидимые три байта UTF-8 BOM тоже считаются выводом.
Небезопасные cookie: угон сессии
По умолчанию идентификатор сессии живёт в cookie без флагов HttpOnly, Secure и SameSite, если вы их явно не задали. Без HttpOnly любой XSS позволяет прочитать document.cookie и увести сессию. Без Secure идентификатор уходит по HTTP в открытом виде. Задавайте параметры до старта:
<?php
// PHP 8.x, единый вызов через массив опций
session_set_cookie_params([
'lifetime' => 0, // до закрытия браузера
'path' => '/',
'domain' => 'example.com',
'secure' => true, // только по HTTPS
'httponly' => true, // недоступно из JS
'samesite' => 'Lax', // защита от части CSRF
]);
session_start();
Значение SameSite выбирайте по задаче: Lax подходит большинству сайтов, Strict ломает переходы по внешним ссылкам с сохранением сессии, None требует обязательного Secure и нужен только при межсайтовых сценариях.
Флаги cookie задаются до session_start(), а не после. После старта вызов session_set_cookie_params() уже ни на что не влияет — заголовок Set-Cookie к этому моменту сформирован.
Фиксация сессии: не сменили ID при логине
Атака на фиксацию сессии (session fixation*) работает так: злоумышленник подсовывает жертве известный ему идентификатор сессии, жертва под ним логинится, и злоумышленник получает уже аутентифицированную сессию с тем же ID. Защита одна — менять идентификатор в момент смены уровня привилегий, то есть сразу после успешного входа:
<?php
// после проверки логина и пароля
session_regenerate_id(true); // true — удалить старый файл сессии
$_SESSION['user_id'] = $user->id;
* Session fixation — атака, при которой идентификатор сессии навязывается жертве заранее, а не крадётся после аутентификации.
Тот же приём применяйте при выходе и при повышении прав. Забыли session_regenerate_id() — и вход по чужой ссылке с параметром сессии (если у вас включён session.use_trans_sid) превращается в дыру.
Блокировка файла сессии: запросы встают в очередь
Хранилище по умолчанию — файлы, и PHP держит эксклюзивную блокировку файла сессии от session_start() до конца скрипта или до session_write_close(). Пока один запрос от пользователя держит блокировку, второй его же запрос ждёт. На SPA, которое шлёт пять параллельных AJAX-запросов, это превращается в последовательное выполнение: пять запросов по 200 мс становятся секундой.
Признак — запросы одного пользователя обрабатываются строго по очереди, при этом сервер не нагружен. Решение: закрывайте сессию, как только перестали в неё писать.
<?php
session_start();
$userId = $_SESSION['user_id'] ?? null;
// больше в сессию не пишем — освобождаем блокировку
session_write_close();
// дальше идёт долгая работа, другие запросы уже не ждут
$data = fetchHeavyReport($userId);
Если запрос только читает сессию и точно ничего не пишет, на PHP 8.x можно открыть её в режиме только для чтения — тогда блокировка не берётся вовсе:
<?php
session_start(['read_and_close' => true]);
// $_SESSION доступен для чтения, блокировка снята сразу
Сессия теряется между поддоменами
Пользователь логинится на example.com, переходит на app.example.com и снова видит форму входа. Причина — cookie сессии привязана к конкретному хосту, если domain не задан. Чтобы cookie работала на всех поддоменах, укажите домен с ведущей точкой опущенной (современные браузеры сами распространяют cookie на поддомены при указании базового домена):
- задайте
'domain' => 'example.com'в параметрах cookie; - проверьте, что
pathравен'/', а не пути конкретного раздела; - убедитесь, что оба поддомена ходят по одной схеме — при
secure = truecookie не уйдёт по HTTP.
Отдельная ловушка: при разных хостах у вас должно быть общее хранилище сессий. Если поддомены обслуживают разные серверы с локальными файлами сессий, идентификатор совпадёт, а данных на втором сервере не окажется. Тогда переносите хранилище в общий Redis или Memcached через session.save_handler.
Сравнение способов хранения
| Хранилище | Плюс | Минус | Когда брать |
|---|---|---|---|
| Файлы (по умолчанию) | Ничего не настраивать | Блокировка файла, не масштабируется на несколько серверов | Один сервер, простой сайт |
| Redis | Общее хранилище, TTL из коробки, быстро | Нужен сам Redis и расширение | Несколько серверов, высокая нагрузка |
| Memcached | Быстро, простое | Данные не переживают перезапуск | Кластер без требований к сохранности |
| База данных | Данные видны в SQL, легко чистить | Медленнее памяти, нагрузка на БД | Когда сессии нужно анализировать |
Что ещё стоит проверить
- Время жизни файлов. Сборщик мусора чистит старые сессии по
session.gc_maxlifetime. На Debian и Ubuntu уборку часто делает системный cron, а не сам PHP — тогда значение в php.ini не работает так, как вы ждёте, и сессии живут дольше или короче. - Хранение крупных объектов. В
$_SESSIONкладут целые выборки из БД «чтобы не запрашивать снова». На большом трафике это раздувает файлы и замедляет сериализацию на каждом запросе. - Незакрытая сессия на API. Если эндпоинт вообще не работает с пользователем, не вызывайте
session_start()— лишняя блокировка и запись файла на пустом месте.
Что делать дальше
Пройдитесь по своим точкам входа и проверьте три вещи: session_start() стоит до любого вывода, флаги cookie заданы явно, а после логина вызывается session_regenerate_id(true). Затем посмотрите в APM или в логи, нет ли запросов одного пользователя, которые ждут друг друга — это признак блокировки, которую снимает session_write_close(). Если серверов больше одного, переводите хранилище в Redis. Точные имена директив и их поведение в вашей версии сверяйте в разделе про сессии официальной документации PHP — часть значений менялась между 7.x и 8.x.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: PHP Manual — Session handling, PHP Manual — session_set_cookie_params
Частые вопросы
У меня PHP 7.4, а не 8.x — эти приёмы сработают?
Сделал всё по инструкции, но сессия всё равно слетает после логина. Что смотреть?
Обязательно ли вызывать session_write_close() руками?
Насколько опасно не менять ID сессии при входе?
Можно ли хранить в сессии данные пользователя, чтобы не ходить в базу?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.