Сессии и куки: как это работает на самом деле

Простой на вид вопрос, который быстро уходит вглубь: HTTP не помнит клиента — как же сайт узнаёт пользователя?

Сессия — это данные на стороне сервера, привязанные к идентификатору. Кука — небольшая строка на стороне клиента, которую браузер шлёт обратно с каждым запросом. Сессия обычно опирается на куку, но это разные вещи.

Вопрос 1: «Как работает session_start()

Что проверяет интервьюер

Понимаете ли вы, что в куке лежит только идентификатор, а сами данные — на сервере. Пошагово:

  1. session_start() ищет идентификатор в куке PHPSESSID (имя настраивается).
  2. Если куки нет, PHP генерирует новый идентификатор и отдаёт его заголовком Set-Cookie.
  3. По идентификатору читается хранилище — по умолчанию файл вида /var/lib/php/sessions/sess_<id> — и его содержимое попадает в $_SESSION.
  4. В конце запроса массив сериализуется обратно в хранилище. Файл на это время блокируется.
<?php
session_set_cookie_params([
    'lifetime' => 0,          // до закрытия браузера
    'path'     => '/',
    'secure'   => true,       // только по HTTPS
    'httponly' => true,       // недоступна из JavaScript
    'samesite' => 'Lax',      // защита от CSRF
]);
session_start();

$_SESSION['user_id'] = 42;

// закрыть запись пораньше, чтобы снять блокировку файла
session_write_close();

Про блокировку спрашивают отдельно: пока один запрос держит сессию открытой, параллельные запросы того же пользователя (например, десяток AJAX-вызовов) выстраиваются в очередь. Лечится вызовом session_write_close() сразу после того, как запись в $_SESSION больше не нужна, или переносом хранилища в Redis.

Вопрос 2: «Какие флаги должны стоять у куки авторизации?»

HttpOnlyкука недоступна из JavaScript — украсть её через XSS уже нельзя
Secureбраузер шлёт куку только по HTTPS
SameSite=Laxкука не отправляется при межсайтовых POST-запросах — базовая защита от CSRF
SameSite=Strictстроже: не отправляется даже при переходе по ссылке с другого сайта
Domain / Pathограничивают область действия куки

Сильный кандидат добавит: срок жизни сессии на сервере (session.gc_maxlifetime) и срок жизни куки — это разные настройки, и рассинхрон между ними даёт классический баг «вроде залогинен, но данных нет».

Вопрос 3: «Что такое session fixation и как от неё защититься?»

Атакующий заранее подсовывает жертве известный ему идентификатор сессии (через ссылку с параметром или через XSS), жертва входит в аккаунт — и сессия с уже известным id становится авторизованной. Защита в одну строку: менять идентификатор при смене уровня привилегий.

// после успешной проверки логина и пароля
session_regenerate_id(true);   // true — удалить старый файл сессии
$_SESSION['user_id'] = $user->id;
$_SESSION['ip'] = $_SERVER['REMOTE_ADDR'];

// при выходе
$_SESSION = [];
session_destroy();
setcookie(session_name(), '', time() - 3600, '/');

Полезно упомянуть и session.use_strict_mode=1: с этой настройкой PHP отказывается принимать идентификатор, который он сам не выдавал.

Вопрос 4: «Как проверить, что кука не подделана?»

Если вы храните что-то в куке напрямую (например, «запомнить меня» или язык интерфейса), значение нужно подписывать. Подпись считают через HMAC с серверным секретом, а сравнивают константным по времени сравнением — обычное === для строк теоретически позволяет атаку по времени.

<?php
$secret = 'ключ-приложения';
$sessionId = 'a1b2c3';

$mac = hash_hmac('sha256', $sessionId, $secret);
echo substr($mac, 0, 16), "\n";

var_dump(hash_equals($mac, hash_hmac('sha256', $sessionId, $secret)));
var_dump(hash_equals($mac, hash_hmac('sha256', 'подделка', $secret)));

Вывод:

4fb499593966da78
bool(true)
bool(false)

И главное правило: в куке не должно быть ни пароля, ни роли пользователя, ни «is_admin=1». Всё, что влияет на права, живёт на сервере — в сессии или в подписанном токене вроде JWT, подпись которого проверяется на каждом запросе.

Типичные ошибки кандидатов

  • Говорят, что данные сессии хранятся в куке. В куке только идентификатор.
  • Не знают про блокировку файла сессии и удивляются, почему параллельные AJAX-запросы «тормозят».
  • Забывают session_regenerate_id() после логина — прямая дорога к session fixation.
  • Ставят SameSite=None без Secure — браузер такую куку просто отбросит.
  • Пишут session_start() после вывода HTML и получают ошибку «headers already sent»: куки — это заголовки, они идут до тела ответа.
  • Хранят роль пользователя в куке и удивляются, что её меняют в DevTools.

Как ответить кратко

«В куке PHPSESSID лежит только идентификатор, данные — на сервере: по умолчанию файл, на проде обычно Redis. session_start() читает хранилище в $_SESSION и держит блокировку до конца запроса, поэтому запись стоит закрывать через session_write_close(). Куку авторизации выдаю с HttpOnly, Secure и SameSite=Lax, после успешного логина обязательно вызываю session_regenerate_id(true) против session fixation, а права пользователя храню только на сервере».

Проверьте себя
1. Где хранятся данные $_SESSION при стандартной настройке PHP?
AНа сервере (по умолчанию в файле), а в куке лежит только идентификатор сессии
BВ куке браузера в зашифрованном виде
CВ скрытом поле HTML-формы
DВ локальном хранилище браузера
2. Зачем вызывать session_regenerate_id(true) сразу после успешного входа?
AЧтобы очистить корзину пользователя
BЧтобы защититься от session fixation — атакующий не сможет использовать заранее подсунутый идентификатор
CЧтобы ускорить запись сессии на диск
DЧтобы продлить срок жизни куки
3. Что даёт флаг куки HttpOnly?
AКука передаётся только по HTTPS
BКука не отправляется при межсайтовых запросах
CКука недоступна JavaScript и её нельзя украсть через XSS
DКука живёт только до закрытия браузера