HTTP не пам'ятає попередній запит, тому стан доводиться відновлювати з того, що клієнт надсилає щоразу. session_start() шукає в запиті cookie з іменем session.name (типово PHPSESSID). Знайшов - читає за цим ідентифікатором файл зі сховища, десеріалізує його у $_SESSION, а в кінці запиту записує назад. Не знайшов - генерує новий ID і додає до відповіді заголовок Set-Cookie. У браузер їде лише рядок ідентифікатора; ані user_id, ані кошик, ані прапорці ролей у cookie не потрапляють. Тому півмегабайта в сесії ніяк не «роздуває трафік», зате роздуває сховище і час на серіалізацію кожного запиту.
Де саме лежать дані, вирішує session.save_handler. За замовчуванням це files, тобто файли sess_<id> у session.save_path. У файлового обробника є особливість, за яку його найчастіше лають: на час запиту він тримає ексклюзивне блокування файла. Поки одна вкладка вивантажує звіт, друга висить на session_start(). Рятує порядок дій: усі записи в $_SESSION робимо на початку, далі викликаємо session_write_close(), і блокування знято. Альтернативні обробники (redis, memcached, власний клас на SessionHandlerInterface) блокування або не ставлять, або ставлять за окремою опцією. Вони ж потрібні, коли серверів більше одного: файлова сесія прив'язана до конкретної машини, а користувач після балансування опиняється на іншій.
За безпеку сесійного cookie відповідають три атрибути, і кожен закриває свою загрозу. HttpOnly прибирає значення з document.cookie, тому XSS-скрипт уже не вичитає ідентифікатор одним рядком. Secure не дає надіслати cookie по HTTP, тобто прибирає перехоплення в дорозі. SameSite (доступний із PHP 7.3) керує тим, чи додасть браузер cookie до запиту з чужого сайту, і на шляху CSRF стоїть саме він, а не HttpOnly. Усі три задаються session_set_cookie_params() строго до session_start() або в php.ini через session.cookie_httponly, session.cookie_secure, session.cookie_samesite. Є ще пара налаштувань, про які часто забувають: session.use_strict_mode=1, щоб PHP не підхоплював ідентифікатор, якого сам не видавав, і session_regenerate_id(true) одразу після успішного логіну. Без них атака з фіксацією сесії робиться посиланням із заздалегідь підставленим ID.
Тепер про API. Сесія працює як неявний стан: браузер сам чіпляє cookie до кожного запиту на домен, а сервер по ньому відновлює контекст. Для класичного сайту це зручно, для API дає три різні проблеми. Автоматичне надсилання відкриває CSRF, тому кожен ендпоїнт, що змінює дані, тягне за собою токен і перевірку. Серверний стан прив'язує запит до сховища сесій, і будь-яке масштабування впирається в спільний Redis або в sticky sessions. І сам механізм не працює там, де немає браузера: мобільний застосунок, cron іншого сервісу, інтеграція партнера не мають ані cookie jar, ані спільного домену. Тому API авторизують явними обліковими даними в заголовку: Authorization: Bearer <token>, де токен - або непрозорий рядок, за яким у базі лежить запис із власником, правами й строком дії, або підписаний JWT, який перевіряється без походу в базу. У базі при цьому зберігають не сам токен, а його хеш, і звіряють через hash_equals(), точно як пароль.
Межу тут проводять по клієнту, а не по ярлику «сайт проти API». Якщо SPA живе на тому самому домені, cookie-сесія лишається кращим варіантом: HttpOnly робить токен недоступним для JS, чого localStorage не дає в принципі, а CSRF закривається SameSite плюс звичайним CSRF-токеном (у Laravel цей режим збирає Sanctum). Щойно з'являється сторонній домен, мобільний клієнт або сервіс-до-сервісу, cookie перестає бути зручним, і починається токен із коротким строком життя й механізмом оновлення. Змішувати обидва в одному ендпоїнті не варто: набір перевірок у них різний, і ендпоїнт, який приймає і сесію, і токен, зазвичай зрештою приймає щось третє.
declare(strict_types=1);
// Параметри cookie задаються ДО старту сесії, пізніше вони вже не діють
session_set_cookie_params([
'lifetime' => 0, // 0 = cookie живе до закриття браузера
'path' => '/',
'domain' => '', // порожньо: поточний хост без піддоменів
'secure' => true, // не піде по відкритому HTTP
'httponly' => true, // недосяжний з document.cookie
'samesite' => 'Lax', // атрибут доступний з PHP 7.3
]);
ini_set('session.use_strict_mode', '1'); // не приймати ID, якого сервер не видавав
session_start(); // читає PHPSESSID із запиту або створює новий
if ($isSuccessfulLogin) {
// від фіксації сесії рятує саме новий ID на зміні привілеїв
session_regenerate_id(true); // true - видалити старий файл сесії
$_SESSION['user_id'] = $user->id;
}
$_SESSION['last_seen'] = time();
session_write_close(); // знімає блокування файла: паралельні запити не чекатимуть
buildHeavyReport(); // довга робота вже поза блокуванням сесії
// --- Той самий користувач, але ендпоїнт API: жодного session_start() ---
$authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (! str_starts_with($authorization, 'Bearer ')) {
http_response_code(401);
header('WWW-Authenticate: Bearer');
exit;
}
// у базі лежить лише хеш токена, порівняння - стійке до таймінг-атак
$tokenHash = hash('sha256', substr($authorization, 7));
$userId = findUserIdByTokenHash($tokenHash); // всередині hash_equals()
if ($userId === null) {
http_response_code(401);
exit;
}
Скажіть одним реченням: «cookie - це квиток з номером, дані по номеру лежать на сервері». Далі назвіть три атрибути й що саме кожен закриває, і згадайте `session_write_close()` перед довгою операцією. Про API формулювання таке: сесія - це неявний стан, який браузер надсилає сам, а токен клієнт додає свідомо, і саме тому CSRF для нього не існує.