Cookie: что нужно знать
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Cookie — единственное хранилище браузера, которое ездит на сервер само: браузер прикладывает его к каждому запросу к домену, даже к запросу картинки. Отсюда и крошечный размер, и целый набор правил — path, domain, Secure, HttpOnly, SameSite, — которые стоит выучить до того, как положить туда что-то важное.
Cookie придумали задолго до localStorage, но заменить их им нельзя — у них разная работа. Cookie нужны серверу: браузер молча добавляет их к каждому запросу к домену, поэтому сервер узнаёт вас уже в первом байте ответа, ещё до того как выполнится хоть одна строчка JavaScript. Именно так работает вход в аккаунт: страница приходит с сервера уже вашей.
localStorage из прошлой статьи — «localStorage и sessionStorage» — в этом смысле личный ящик клиента: сервер о нём не знает и не узнает, пока вы сами не отправите содержимое. Отсюда простое правило выбора. Данные нужны серверу на каждом запросе — cookie. Данные нужны только странице (черновик формы, выбранная тема, кэш ответа) — localStorage. И помните про цену: каждый лишний килобайт в cookie — это лишний килобайт в каждом запросе.
| Свойство | cookie | localStorage |
|---|---|---|
| Размер | около 4 КБ на запись, порядка 50 записей на домен | около 5 МБ на origin |
| Уходит на сервер | да, автоматически с каждым запросом | нет, никогда |
| Срок жизни | задаёте сами: до закрытия браузера или до даты | пока не удалят |
| Видимость для JS | можно закрыть флагом HttpOnly | всегда открыт |
| Область видимости | домен и путь, при желании с поддоменами | строго один origin |
| Кто может записать | сервер (заголовок Set-Cookie) или JS | только JS |
document.cookie выглядит как обычное свойство, но ведёт себя странно: при чтении отдаёт все cookie этой страницы одной строкой вида a=1; b=2, без сроков, путей и флагов — только пары. А при записи это не присваивание, а команда: браузер разбирает строку и добавляет или обновляет одну cookie, остальные не трогает.
В значении нельзя использовать ;, запятую, пробел и другие служебные символы, поэтому имя и значение прогоняют через encodeURIComponent, а при чтении — через decodeURIComponent. Готового парсера в браузере нет, помощник на пять строк придётся написать самому.
Строковый формат document.cookie — привет из девяностых: одна строка, которую каждый разбирает сам. Асинхронный CookieStore API уже умеет отдавать объекты без парсера, но поддержан пока не везде, так что помощник на пять строк носить с собой ещё придётся.
Без атрибутов срока cookie сеансовая: она исчезнет вместе с окном браузера. Чтобы она пережила перезапуск, добавляют max-age (сколько секунд жить, самый удобный вариант) или expires (точная дата в формате UTC). Отрицательный или нулевой max-age удаляет cookie — другого способа удаления нет, delete тут бесполезен.
path ограничивает cookie каталогом: при path=/ она видна на всём сайте, а по умолчанию — только в каталоге текущей страницы и ниже. Почти всегда нужен path=/, и почти всегда его забывают. domain по умолчанию сужен до текущего хоста; domain=site.ru раздаёт cookie всем поддоменам, а чужой домен указать нельзя — браузер просто отбросит запись.
Классическая головоломка: пишете max-age=0, а cookie на месте. Значит, вы удаляете не ту: браузер считает cookie разными, если у них разные path или domain, и рядом со старой просто появится новая пустая. Удаление обязано повторить ровно тот же path и domain, что были при записи.
Флаги — не украшения, а границы, за которые cookie не выйдет. Secure запрещает отправку по обычному http. HttpOnly прячет cookie от JavaScript целиком: её нет в document.cookie, её не украдёт чужой скрипт. SameSite решает, поедет ли cookie в запросах, которые инициировал другой сайт, — это главная защита от CSRF.
Важная деталь: HttpOnly из JavaScript поставить невозможно — и попытка не просто игнорирует атрибут, а отбрасывает всю запись. Флаг существует ровно затем, чтобы код страницы не имел к cookie доступа, поэтому ставит его только сервер заголовком Set-Cookie.
| Флаг | Кто может поставить | Что даёт |
|---|---|---|
| Secure | сервер или JS на https | cookie не уходит по http и на http-странице не сохраняется |
| HttpOnly | только сервер, в Set-Cookie | cookie невидима для любого кода страницы, включая чужой |
| SameSite=Lax | сервер или JS | поведение по умолчанию: cookie едет при переходе по ссылке, но не в фоновых запросах чужого сайта |
| SameSite=Strict | сервер или JS | не едет даже при переходе по ссылке с чужого сайта |
| SameSite=None | сервер или JS, только вместе с Secure | разрешает межсайтовую отправку — для встроенных виджетов и iframe |
Сервер отвечает заголовком Set-Cookie: sid=abc; Path=/; Max-Age=3600; HttpOnly; SameSite=Lax; Secure — по одному заголовку на cookie. Формат тот же, что у строки записи в document.cookie: сначала пара имя-значение, дальше атрибуты через точку с запятой. Разница только в правах: сервер может всё, JS — всё, кроме HttpOnly.
Cookie не зашифрована и ничем не подписана: это просто строка на диске пользователя. Пароль в ней не хранят никогда и ни в каком виде — ни открытым текстом, ни «закодированным» в base64, потому что кодирование это не шифрование. С токеном сессии сложнее: он обязан где-то лежать, и вопрос лишь в том, дотянется ли до него посторонний код.
А дотянется он легко. Всё, что видит ваш скрипт, видит и любой другой скрипт на странице: подключённый виджет, рекламный счётчик, взломанная зависимость, вставка через XSS. Достаточно одной строки document.cookie — и токен уехал. Поэтому правильный ответ на вопрос «где хранить токен» — в cookie, которую поставил сервер с HttpOnly, Secure и SameSite: тогда браузер отправляет её сам, а JavaScript её просто не видит.
Вторая опасность — обратная сторона удобства: раз cookie уходит автоматически, чужой сайт может заставить браузер сделать запрос к вашему серверу вместе с ней. Это CSRF, и лечится он тем же SameSite плюс отдельным токеном в форме. И последнее: не кладите в cookie персональные данные. Они уезжают на сервер с каждой картинкой и оседают в логах всех прокси по дороге.
- Cookie — маленькая строка, которую браузер сам прикладывает к каждому запросу к домену; localStorage не уезжает никуда и служит только странице.
- document.cookie — не переменная, а интерфейс: читается вся строка пар сразу, пишется по одной cookie, атрибуты обратно не читаются.
- Без max-age или expires cookie живёт до закрытия браузера; удаление — это запись с нулевым сроком и теми же path и domain.
- Secure, HttpOnly и SameSite закрывают cookie соответственно от http, от JavaScript и от запросов чужих сайтов; HttpOnly ставит только сервер.
- Пароль в cookie не хранят никогда, а токен сессии — только в серверной cookie с HttpOnly, Secure и SameSite: всё, что видит ваш скрипт, видит и чужой.
1/3Сервер поставил cookie sid с флагом HttpOnly, а скрипт страницы записал theme=dark. Что вернёт document.cookie?
2/3На странице уже есть cookie a=1 и b=2. Выполняется document.cookie = "a=3; path=/". Что получится?
3/3Cookie x была поставлена с path=/admin. Как её удалить?
Три функции для работы с cookie
Напишите setCookie(name, value, options), getCookie(name) и deleteCookie(name, path). Имя и значение кодируйте через encodeURIComponent, по умолчанию используйте path=/, поддержите опции maxAge, path, sameSite, secure. getCookie возвращает раскодированное значение или undefined, если такой cookie нет.
Вся хитрость в двух местах: значение обязательно кодируется, а удаление — это та же запись с нулевым сроком и тем же путём.
Обратите внимание на options.maxAge !== undefined: проверка на истинность съела бы maxAge: 0, то есть ровно тот случай, когда cookie надо удалить.
Собрать строку cookie по опциям
Напишите чистую функцию buildCookie(name, value, options), которая ничего не записывает, а возвращает готовую строку. Порядок атрибутов: path, max-age, samesite, secure; отсутствующие опции пропускаются, secure идёт без значения.
Собираем части в массив и склеиваем — так не приходится следить за лишними точками с запятой.
Такую функцию удобно тестировать без браузера: она не трогает document, а результат можно просто сравнить со строкой.