Cookie: что нужно знать

хранение≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Cookie — единственное хранилище браузера, которое ездит на сервер само: браузер прикладывает его к каждому запросу к домену, даже к запросу картинки. Отсюда и крошечный размер, и целый набор правил — path, domain, Secure, HttpOnly, SameSite, — которые стоит выучить до того, как положить туда что-то важное.

Разделы темы:·

Cookie придумали задолго до localStorage, но заменить их им нельзя — у них разная работа. Cookie нужны серверу: браузер молча добавляет их к каждому запросу к домену, поэтому сервер узнаёт вас уже в первом байте ответа, ещё до того как выполнится хоть одна строчка JavaScript. Именно так работает вход в аккаунт: страница приходит с сервера уже вашей.

localStorage из прошлой статьи — «localStorage и sessionStorage» — в этом смысле личный ящик клиента: сервер о нём не знает и не узнает, пока вы сами не отправите содержимое. Отсюда простое правило выбора. Данные нужны серверу на каждом запросе — cookie. Данные нужны только странице (черновик формы, выбранная тема, кэш ответа) — localStorage. И помните про цену: каждый лишний килобайт в cookie — это лишний килобайт в каждом запросе.

СвойствоcookielocalStorage
Размероколо 4 КБ на запись, порядка 50 записей на доменоколо 5 МБ на origin
Уходит на серверда, автоматически с каждым запросомнет, никогда
Срок жизнизадаёте сами: до закрытия браузера или до датыпока не удалят
Видимость для JSможно закрыть флагом HttpOnlyвсегда открыт
Область видимостидомен и путь, при желании с поддоменамистрого один origin
Кто может записатьсервер (заголовок Set-Cookie) или JSтолько JS

Без атрибутов срока cookie сеансовая: она исчезнет вместе с окном браузера. Чтобы она пережила перезапуск, добавляют max-age (сколько секунд жить, самый удобный вариант) или expires (точная дата в формате UTC). Отрицательный или нулевой max-age удаляет cookie — другого способа удаления нет, delete тут бесполезен.

path ограничивает cookie каталогом: при path=/ она видна на всём сайте, а по умолчанию — только в каталоге текущей страницы и ниже. Почти всегда нужен path=/, и почти всегда его забывают. domain по умолчанию сужен до текущего хоста; domain=site.ru раздаёт cookie всем поддоменам, а чужой домен указать нельзя — браузер просто отбросит запись.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Классическая головоломка: пишете max-age=0, а cookie на месте. Значит, вы удаляете не ту: браузер считает cookie разными, если у них разные path или domain, и рядом со старой просто появится новая пустая. Удаление обязано повторить ровно тот же path и domain, что были при записи.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Флаги — не украшения, а границы, за которые cookie не выйдет. Secure запрещает отправку по обычному http. HttpOnly прячет cookie от JavaScript целиком: её нет в document.cookie, её не украдёт чужой скрипт. SameSite решает, поедет ли cookie в запросах, которые инициировал другой сайт, — это главная защита от CSRF.

Важная деталь: HttpOnly из JavaScript поставить невозможно — и попытка не просто игнорирует атрибут, а отбрасывает всю запись. Флаг существует ровно затем, чтобы код страницы не имел к cookie доступа, поэтому ставит его только сервер заголовком Set-Cookie.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
ФлагКто может поставитьЧто даёт
Secureсервер или JS на httpscookie не уходит по http и на http-странице не сохраняется
HttpOnlyтолько сервер, в Set-Cookiecookie невидима для любого кода страницы, включая чужой
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.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Cookie не зашифрована и ничем не подписана: это просто строка на диске пользователя. Пароль в ней не хранят никогда и ни в каком виде — ни открытым текстом, ни «закодированным» в base64, потому что кодирование это не шифрование. С токеном сессии сложнее: он обязан где-то лежать, и вопрос лишь в том, дотянется ли до него посторонний код.

А дотянется он легко. Всё, что видит ваш скрипт, видит и любой другой скрипт на странице: подключённый виджет, рекламный счётчик, взломанная зависимость, вставка через XSS. Достаточно одной строки document.cookie — и токен уехал. Поэтому правильный ответ на вопрос «где хранить токен» — в cookie, которую поставил сервер с HttpOnly, Secure и SameSite: тогда браузер отправляет её сам, а JavaScript её просто не видит.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вторая опасность — обратная сторона удобства: раз 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 нет.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Вся хитрость в двух местах: значение обязательно кодируется, а удаление — это та же запись с нулевым сроком и тем же путём.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание на options.maxAge !== undefined: проверка на истинность съела бы maxAge: 0, то есть ровно тот случай, когда cookie надо удалить.

Собрать строку cookie по опциям

Напишите чистую функцию buildCookie(name, value, options), которая ничего не записывает, а возвращает готовую строку. Порядок атрибутов: path, max-age, samesite, secure; отсутствующие опции пропускаются, secure идёт без значения.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Собираем части в массив и склеиваем — так не приходится следить за лишними точками с запятой.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Такую функцию удобно тестировать без браузера: она не трогает document, а результат можно просто сравнить со строкой.