Кукі, сесії та сховище браузера
Зміст
HTTP не памʼятає попереднього запиту: сервер дивиться на кожен запит так, ніби бачить вас уперше (це властивість протоколу з глави «Клієнт-сервер і як працює веб»; чому вона корисна — у першому розділі нижче). Тому застосунок мусить сам памʼятати, що ви залогінені, і доводити це серверу з кожним запитом заново. Місць для такої памʼяті три, і глава розбере кожне окремо:
- кукі (cookie) — невеликий запис виду «імʼя=значення»; браузер зберігає його й сам повертає серверу;
- сесія на сервері — самі дані лежать на сервері, а браузер тримає лише короткий ідентифікатор до них; що це за ідентифікатор — розділ про сесію;
- сховища браузера — дані, які на сервер самі не їдуть ніколи.
Ця глава — про те, де саме лежить цей стан і хто вирішує, чи доїде він до сервера. Це канонічна глава теми — повний виклад тут, сусідні глави посилаються сюди.
Тестувальник, який не знає цієї карти, пише хибні репорти й губить на них години. «Кукі в DevTools є — отже, вона поїхала на сервер»: ні, збереження і відправка — різні факти. «Користувач розлогінений — баг бекенду»: часто це атрибут кукі, який заборонив доставку. «Почистили localStorage — тести ізольовані»: а серверний стан нікуди не дівся. Наприкінці глави весь цей матеріал зійдеться в автотестах: там стан логіну зберігають одним файлом, і файл ламається рівно об ті самі атрибути.
Чому сервер не памʼятає нас між запитами
Спершу — властивість, через яку все це взагалі потрібне. HTTP — протокол без стану: сервер не запамʼятовує, від кого прийшов попередній запит. Цю властивість називають (statelessness), і вона належить протоколу, а не застосунку: специфікація прямо вимагає, щоб кожен запит був зрозумілий сам по собі, без огляду на попередні.
Правило суворіше, ніж здається. Навіть якщо два запити прийшли одним зʼєднанням, сервер не має припускати, що їх надіслав той самий агент — так специфікація називає програму, яка робить запити, найчастіше браузер.
Навіщо протоколу така забудькуватість? Мотив прагматичний — масштабованість. Великий сайт — це зазвичай не один сервер, а десяток однакових; на вході стоїть балансувальник — сервер-диспетчер, який роздає вхідні запити між ними. Якщо серверу не треба памʼятати клієнтів, то будь-який із десятка обробить будь-який запит.
Стан у застосунку при цьому є майже завжди. Просто він живе не в протоколі, а в базі — і в тому, що клієнт приносить із кожним запитом.
Звідси правило, з якого росте вся глава: клієнт мусить нести доказ «це знову я» в кожному запиті заново, а не показати його один раз при логіні. Механізмів доставки два:
- Кукі. Сервер віддає її заголовком відповіді
Set-Cookie; далі браузер сам вирішує, чи повернути паруімʼя=значенняв заголовкуCookie, — і вирішує за атрибутами, які зберіг разом із кукі. - Токен (token). Його клієнт зберігає сам і сам додає в заголовок
Authorization. Специфікація задає для цього заголовка лише форму: словоBearer, пробіл, даліb64token— так специфікація називає сам токен: рядок із букв і цифр.Bearerанглійською — «предʼявник»: впускають того, хто предʼявив токен; докладніше — в розділі про сесію.
Почнімо з робочої моделі — свідомо спрощеної: сервер поставив кукі — браузер повертає її з кожним наступним запитом на той самий сайт. Для першого дня на проєкті модель годиться. Далі глава зламає її тричі — на шляху, на каналі й на межі сайту. Кожен злам додасть до моделі рівно одне уточнення.
Наша практика (не канон). У специфікації повернення кукі описане як обовʼязок браузера — він робить це сам. Про токен такого правила немає: специфікація каже лише, як має виглядати запит, а додати заголовок мусить код застосунку. Тож висновок «кукі доїде сама, а токен — лише той, який поклав клієнтський код» ми виводимо з того, що специфікація про токен мовчить.
Кукі: сервер пропонує, браузер вирішує
Кукі — пара імʼя=значення, яку сервер надсилає браузеру заголовком Set-Cookie, а браузер зберігає й сам повертає в заголовку Cookie. Перша версія логіну на app.example.com ставила її без жодного атрибута:
POST /api/login HTTP/1.1
Host: app.example.com
HTTP/1.1 200 OK
Set-Cookie: session_id=abc123def456
Set-Cookie: theme=dark; Path=/; Max-Age=31536000
Назад браузер повертає лише пари імʼя=значення, злиті в один заголовок (сама механіка заголовків HTTP — у главі «HTTP: методи, структура, заголовки»):
GET /api/profile HTTP/1.1
Host: app.example.com
Cookie: session_id=abc123def456; theme=dark
Головне в цьому обміні — асиметрія: у два боки їде не те саме. Сервер надіслав кукі з атрибутами. Назад атрибути не їдуть — повертаються самі пари імʼя=значення. Тому із заголовка Cookie сервер не бачить ні коли кукі спливе, ні для яких хостів вона чинна (хост — повне доменне імʼя сервера з адреси, як-от app.example.com), ні чи стояли прапорці захисту.
Ще дві деталі цього заголовка. Порядок пар — не контракт: специфікація радить серверам на нього не покладатися. А сенсу окремої кукі специфікація не визначає взагалі: що означає theme=dark, знає лише застосунок.
І ще одне: ставити кукі може не лише сервер. Клієнтський код теж пише їх — через document.cookie. Навіщо це памʼятати вже зараз: нижче зʼявиться атрибут HttpOnly — він закриває скриптам саме цей доступ до кукі.
І ця версія логіну видала перший сюрприз одразу: після успішного POST /api/login перехід на GET /dashboard повертає нас на форму логіну, хоча кукі session_id нікуди не ділася. Це видно в DevTools: панель Application показує все, що браузер зберіг для сайту, і кукі там на місці. Кукі в браузері є, запит іде на той самий хост — чому вона не поїхала?
Тому що «браузер повертає кукі на той самий сайт» — і є те спрощення, яке ламається першим. Насправді браузер вирішує за атрибутами кукі. Якщо якогось атрибута немає, браузер підставляє значення за замовчуванням. І дефолтний Path — це не корінь сайту /. Це «тека» того шляху, звідки приїхав Set-Cookie. Наша кукі приїхала з відповіді на POST /api/login, тож її дефолтний Path — /api. Тому кукі їде на /api/profile, але не на /dashboard. Той самий дефолт браузер підставить і замість порожнього чи некоректного Path. Це найчастіша причина симптому «кукі в DevTools є, а на запит не йде» — і перше уточнення моделі: не «на той самий сайт», а «на запити, що проходять атрибути». Виправлення — одна деталь у відповіді логіну:
Set-Cookie: session_id=abc123def456; Path=/
При цьому Path — про доставку, не про безпеку: він не дає ні конфіденційності, ні цілісності, тож розробники не використовують його як межу захисту.
Тут же видно, чому в session_id їде короткий ключ, а не самі дані про користувача: кукі — тісний механізм, і тісним його зробили навмисно. Специфікація не гарантує браузерам великих обсягів. Вона задає лише мінімуми, які браузеру варто підтримувати (у тексті специфікації це SHOULD — рекомендація, не жорстка вимога):
- щонайменше 4096 байтів на кукі — сумарна довжина імені, значення й атрибутів;
- 50 кукі на домен;
- 3000 кукі всього.
Серверам специфікація виставляє дзеркальну вимогу: тримати кукі якомога меншими і нечисленними, бо заголовок Cookie їде з кожним запитом. Гарантій повернення теж немає: браузер має право будь-коли витіснити будь-яку кукі.
Що саме ховається за коротким ключем, розкаже розділ про серверну сесію. Куди класти те, що в кукі не влазить, — покаже розділ про сховища. І останній дефолт: кукі без атрибутів повертається лише тому серверу, який її поставив, і зникає в кінці сесії браузера.
Атрибути: правила доставки, які виконує браузер
Отже, долю кукі вирішують атрибути. Ось повний набір — а далі розберемо ті, на яких ламаються тести.
| Атрибут | Що задає | Наслідок |
|---|---|---|
Domain | На які хости надсилати | Без нього кукі host-only — її бачить лише хост, що її поставив |
Path | На які шляхи надсилати | Дефолт — «тека» шляху, що поставив кукі, не / |
Expires | Граничний момент життя як дату | Після нього браузер кукі видаляє |
Max-Age | Те саме в секундах | За наявності обох пріоритет має Max-Age |
Secure | Слати лише захищеним каналом | По відкритому HTTP не поїде — виняток localhost |
HttpOnly | Закрити від читання скриптом | document.cookie її не бачить |
SameSite | Поведінку в міжсайтових запитах | Strict / Lax / None — окремий розділ нижче |
Domain розширює, а не звужує. Тобто атрибут робить кукі видимою для ширшого кола хостів, а не для вужчого. Наступне рішення команди: чи має сесію бачити api.example.com? Поки в кукі немає атрибута Domain, діє правило за замовчуванням: кукі бачить лише той хост, який її поставив, — таку кукі називають host-only. Поставив app.example.com — бачить тільки app.example.com. Піддомени, як-от api.example.com, — ні.
Заданий Domain=example.com знімає це обмеження: кукі поїде на всі піддомени example.com, зокрема тестові стенди (staging — окрема копія сайту для перевірок). На чужий домен розширитись не вийде: хост сервера, який ставить кукі, має входити в домен із Domain — як app.example.com входить у example.com. Кукі з доменом, куди хост не входить, браузер просто відкине. Ми лишили host-only. «Сесія на всі піддомени» — ширша поверхня для , ніж нам потрібно.
Час життя ділить кукі на два види. Наша session_id — без Expires і Max-Age. Така кукі називається сесійною: вона зникає, коли завершується сесія браузера. Типова роль сесійної кукі — саме ідентифікатор сесії. Другий вид — постійна кукі, як theme=dark із Max-Age=31536000: вона переживає перезапуск браузера. Так реалізують «запамʼятати мене». Одне застереження: «закрив браузер — розлогінився» справджується не завжди. Багато браузерів відновлюють вкладки після перезапуску — і разом із ними зберігають сесійні кукі.
Окреме питання — що для браузера означає «та сама» кукі. Її «посвідчення» — трійка «імʼя + домен + шлях»: нова кукі з тією самою трійкою витісняє стару. Через ту саму трійку працює й видалення: сервер надсилає той самий заголовок Set-Cookie з тією ж трійкою, але з Expires у минулому або з Max-Age нуль чи відʼємним — і браузер кукі прибирає.
Secure захищає канал — і тільки канал. Після глави «HTTPS, TLS і безпека» логічний крок — заборонити кукі їздити відкритим текстом. Для цього додаємо атрибут Secure. Браузер і тут діє сам: таку кукі він не віддасть незахищеному зʼєднанню. Але в цього захисту дві межі.
Перша межа: Secure не рятує від перезапису. По кроках:
- контролює мережу між браузером і сервером.
- Він відповідає браузеру від імені сайту по відкритому HTTP — і в тій відповіді ставить свою кукі з тим самим іменем.
- Браузер перезапише нею навіть
Secure-кукі — тепер у жертви лежить ідентифікатор сесії, який атакувальник вибрав сам і тому знає наперед.
Саме так працює (session fixation) — атака, у якій жертві підсаджують наперед відомий ідентифікатор сесії.
Друга межа — пастка середовищ. Якщо Secure виставлений завжди, сесія не працює на стендах без TLS, і це не дефект застосунку. Виняток — localhost: для нього браузер робить послаблення, тож локально все «працює».
І ще: перевіряти треба у відповіді сервера, у заголовку Set-Cookie, а не здогадом з архітектури. Буває, що HTTPS закінчується на балансувальнику: він розшифровує трафік, а далі до застосунку запити йдуть уже відкритим HTTP. Браузер при цьому бачить HTTPS, а застосунок — ні. А поведінку кукі визначає те, що бачить браузер.
HttpOnly закриває читання — і тільки читання. Другий прапорець на сесійній куці відрізає скриптам доступ до значення: читання такої кукі через document.cookie повертає порожній рядок. Тепер про те, чого він не робить. Він не блокує XSS — атаку, за якої на сторінку потрапляє чужий скрипт, — і не спиняє самі скрипти. Кукі з HttpOnly так само летить із запитами, які ініціював JavaScript через fetch чи XMLHttpRequest (способи, якими код сторінки сам шле запити). Тобто впроваджений скрипт усе одно діє від імені користувача — просто не бачить значення кукі.
Прапорці незалежні один від одного, і на сесійній куці мають стояти обидва: Secure — проти підслуханого каналу, HttpOnly — проти читання скриптом.
Перевірте на будь-якому сайті, де ви залогінені: виконайте в DevTools document.cookie і порівняйте з таблицею в Application → Cookies. Очікуваний результат: рядок у консолі коротший — усе, чого в ньому бракує, і є HttpOnly-кукі.
Наша кукі тепер виглядає так:
Set-Cookie: session_id=abc123def456; Path=/; Secure; HttpOnly
Тепер найпідступніше. У першому з прапорцями трапилась одруківка — HttpOnl без y. Здається, що зламана кукі не встановиться або хоч якось просигналізує про помилку. Ні. Нерозпізнані атрибути браузер мовчки ігнорує, а саму кукі зберігає. Доказ — одразу в консолі:
document.cookie
// "session_id=abc123def456; theme=dark" ← значення видно: HttpOnly не спрацював
Кукі працює, логін працює, тести зелені — а захисту немає. Побачити це можна лише в самому заголовку Set-Cookie — у вкладці Network або в тесті API-рівня. Перевірити прапорці зі сторінки не можна в принципі: вебплатформа тримає список заголовків відповіді, які скриптам читати заборонено, і Set-Cookie стоїть у цьому списку. Підмінити заголовок Cookie у fetch теж не можна.
Проти таких помилок є окремий рубіж — префікси імені кукі. Ідея: сервер зашиває обіцянку захисту прямо в імʼя кукі — його одруківкою в атрибутах не зіпсуєш, — а браузер відмовиться зберігати кукі, якщо сервер обіцянки не виконав. Префіксів два:
__Secure-— браузер прийме таку кукі, лише якщо вонаSecureі поставлена з HTTPS: обіцянку «тільки захищений канал» він перевіряє вже на кроці збереження;__Host-— ті самі дві умови плюс безDomainі зPath=/: додаткові умови привʼязують кукі рівно до одного хоста й до всього сайту, щоб її не перекрила кукі з піддомену чи вужчого шляху.
І тут є чесна межа: браузер, який префіксів не підтримує, прийме такі кукі завжди. Це страховка, не гарантія.
SameSite: «той самий сайт» — не той самий хост
Останній атрибут нашої кукі — SameSite. Він керує тим, чи додається кукі до запитів, які ініціює інший сайт: наприклад, коли чужа сторінка вбудовує картинку з вашого сайту або відправляє форму на ваш URL. Значень три:
Strict— кукі їде лише в запитах у межах того самого сайту.Lax— те саме плюс один виняток. Кукі поїде, коли збіглися дві умови разом: перехід на ваш сайт — верхньорівнева навігація, і запит іде . Верхньорівнева навігація — адреса змінюється в самому адресному рядку вкладки, а не у вбудованому фреймі (чужій сторінці всередині вашої — про них розділ про сторонні кукі). Безпечний метод — той, що лише читає дані, якGET. Найзвичніший приклад, де збігається і те, і те, — клік по посиланню.None— кукі їде і в крос-сайтових запитах, але лише разом ізSecure. БезSecureкукі зSameSite=Noneбраузер відкидає цілком, ще на кроці збереження. У тестах це виглядає як «сервер її ставить, а її немає».
Ми ставимо явний Lax — і кукі набуває фінального вигляду, який пройде через решту глави:
Set-Cookie: session_id=abc123def456; Path=/; Secure; HttpOnly; SameSite=Lax
Чому явний Lax, а не дефолтний? Кукі без атрибута рушії сімейства Chromium теж трактують як Lax — але це поведінка саме цих рушіїв, не всіх браузерів. Рушій — ядро, на якому побудований браузер; на Chromium працюють Chrome, Edge та багато інших. У крос-браузерних сценаріях на таку поведінку не покладаються.
Є й друга причина: дефолтний Lax дозвільніший за явний. Спершу — висновок для тестувальника: свіжа кукі без SameSite може поїхати з чужого POST — кукі, старша за дві хвилини, вже ні. Механізм — по кроках:
- сценарій: чужий сайт відправляє форму методом
POSTна ваш, і адреса вкладки змінюється на вашу. ЯвнийLaxкукі в такий запит не пустить ніколи; - для кукі без
SameSiteрушій може тимчасово пустити її і в такийPOST. В оригіналі поведінка зветьсяLax-allowing-unsafe— «Lax, що пропускає небезпечні методи», тобто методи, які змінюють дані, якPOST; - послаблення діє, поки кукі не виповнилось дві хвилини (поріг — зі специфікації рушія). Це внутрішня поведінка рушія, а не окреме значення атрибута.
А от одруківка у значенні атрибута — на відміну від одруківки HttpOnl у назві — не роззброює кукі: невідоме значення браузер трактує як Lax. Тобто результат — не «без обмежень», а лаксові правила.
Тепер третій злам моделі — межа «того самого сайту». Інтуїція каже: той самий сайт — це той самий домен. Специфікація каже інакше, і щоб її прочитати, потрібні три слова — від найпростішого до складеного:
- мітка — один сегмент доменного імені між крапками: в
app.example.comміток три; - — частина адреси, під якою будь-хто може зареєструвати собі імʼя:
com,co.uk,github.io; браузери звіряються зі спільним переліком таких суфіксів — списком публічних суфіксів (Public Suffix List); - (registrable domain) — публічний суфікс плюс одна мітка ліворуч від нього.
Сайт — пара «схема + реєстрований домен». Для app.example.com публічний суфікс — com, тож реєстрований домен — example.com. Звірте інтуїцію з таблицею:
| Пара адрес | Same-site? | Чому |
|---|---|---|
app.example.com і api.example.com | так | реєстрований домен один — example.com |
http://example.com і https://example.com | ні | схема входить у межу сайту |
https://site.com:8443 і https://site.com | так | порт у межу не входить |
your-project.github.io і my-project.github.io | ні | github.io — у списку публічних суфіксів |
Схему в означення сайту додали навмисно: якби http:// і https:// рахувалися одним сайтом, незахищений HTTP був би слабким каналом до сесії. Для co.uk межа проходить на третьому рівні: реєстрований домен — на кшталт example.co.uk. У статтях реєстрований домен часто скорочують до eTLD+1 — це розмовна назва, самі джерела кажуть «реєстрований домен».
І в самої цієї межі є слабке місце: список публічних суфіксів у різних клієнтів різниться, тож вебплатформа радить будувати перевірки безпеки не на сайті, а на (origin) — вужчій межі «схема + хост + порт», якій нижче присвячено окремий розділ.
Щодо захисту. Міжсайтова підробка запиту (cross-site request forgery, CSRF) — атака, коли чужий сайт змушує ваш браузер зробити запит від вашого імені. Проти неї Lax дає лише глибину оборони — додатковий шар, а не повноцінний захист. Як виглядає атака — і чому GET, який змінює стан на сервері, зводить цей шар нанівець, — розбирає глава «Автентифікація та авторизація».
Наша практика (не канон). SameSite найчастіше болить у двох сценаріях, яких джерела не описують. Перший — SSO (single sign-on, єдиний вхід через зовнішній сервіс) і платіжні редіректи: користувача веде на зовнішній сайт і повертає назад. Кукі для такого повернення має бути None; Secure, інакше сесія «губиться» на поверненні. Другий — вхід за посиланням із листа чи месенджера: під Strict перший екран виглядає розлогіненим, і це очікувана поведінка, а не баг.
Сторонні кукі: вирішує браузер, а не атрибути
У SameSite правило обирав сервер: він записував його в Set-Cookie, а браузер лише виконував — перед кожним запитом сам перевіряв, чи можна класти куку. Зі сторонніми (third-party) кукі інакше: браузер вирішує сам, за власною політикою, і жоден атрибут у Set-Cookie цього рішення не переважить. «Стороння» — властивість не самої кукі, а пари «кукі × поточна сторінка»: кукі стороння тоді, коли її домен і схема розходяться з тим, що в адресному рядку. Точніша назва — крос-сайтові кукі: поведінка та сама, навіть якщо всі домени — ваші.
Важлива деталь: «свій чи чужий» браузер міряє від адреси самої вкладки — того, що видно в адресному рядку. Звідки саме на сторінці пішов запит — ролі не грає. Сторінка може вбудувати чужий документ через <iframe> — рамку з окремою сторінкою всередині; той документ може вбудувати ще один, і так далі. Для кукі в такому вкладеному фреймі браузер перевіряє всі сторінки-обгортки навколо нього, аж до самої вкладки. Тож те, як поведеться сесійна кукі всередині <iframe>, залежить від адреси самої вкладки.
Дефолти в різних рушіїв різні — і вивести їх нема звідки: це не вимога специфікації, а продуктові рішення команд браузерів, які ще й змінюються з версіями. Тому їх звіряють із документацією; поточна картина по браузерах:
- Firefox — Total Cookie Protection («повний захист кукі»): кожному сайту — своя «банка» кукі. Це ізоляція, а не блокування.
- Safari — блокує сторонні кукі типово. Точково послабити заборону для конкретного фрейму можна лише кодом — через Storage Access API, браузерний інтерфейс, яким вбудований фрейм просить доступ до власних кукі.
- Chrome — типово не блокує. Блокує лише в режимі Incognito (в українському інтерфейсі — «анонімний режим») або за явним налаштуванням.
Наслідок: крос-сайтовий сценарій, що працює в Chrome, може мовчки не працювати в браузері поруч.
Найдалі йде Safari з ITP (Intelligent Tracking Prevention — «розумне запобігання стеженню»). Одразу застереження: усе, що нижче до кінця розділу, — довідка для розбору багів () під Safari, а не матеріал для запамʼятовування. Рішення «цей домен — трекер» ухвалює модель машинного навчання просто на пристрої — за трьома сигналами:
- на скількох різних сайтах домен вантажиться як підресурс — картинка чи скрипт;
- на скількох сайтах він зʼявляється як вбудований фрейм;
- на скільки різних доменів він перенаправляє відвідувачів.
Тому поведінка залежить від історії конкретного профілю браузера — і на чистому профілі відтворюється не завжди.
Далі — три режими. Safari обирає між ними за однією міркою: як давно користувач взаємодіяв із доменом як із першою стороною, тобто відкривав сам сайт, а не зустрічав його вбудованим у чужий. Цифри нижче — з публікації 2017 року; Safari з того часу міняв деталі, тож сприймай їх як порядок величин, а не точні пороги:
- менш як 24 години тому — кукі домену доступні й як сторонні; саме це вікно тримає живим SSO;
- від 24 годин до 30 днів — кукі розкладені по , тобто окремих «банках»: своя на кожен реєстрований домен (у документації WebKit — «TLD+1», ще одна назва того самого реєстрованого домену) і спільна для його піддоменів;
- понад 30 днів — дані вичищаються.
Наслідок для баг-тріажу: «вчора ввійшли — сьогодні ні» в Safari буває очікуваною поведінкою браузера, а не багом застосунку.
Сесія на сервері: у браузері лише ключ
Час чесно сказати, що таке abc123def456. Це не дані про вас — це ключ. Класична схема тримає стан на сервері. Після логіну сервер створює у себе запис сесії — у памʼяті або в базі даних — і кладе туди все: хто ви, які у вас ролі, що в кошику. Браузеру він віддає лише довгий випадковий ідентифікатор — кукою. На кожен наступний запит браузер сам надсилає кукі, сервер знаходить за нею запис і «згадує» вас. Ключова думка діаграми: у браузері ніколи не лежить сам стан — там лежить лише перепустка до нього.
Чому до цього ключа стільки вимог? Через його bearer-природу (bearer — «предʼявник»): сервер перевіряє лише те, що значення дійсне, і не питає, хто саме його надіслав. Хто предʼявив ключ — той і ввійшов. Тому після входу ідентифікатор сесії тимчасово еквівалентний найсильнішому методу автентифікації застосунку: крадіжка сесійної кукі має той самий вплив, що й крадіжка логіна з паролем. Звідси правила, які варто перевіряти як вимоги:
- ідентифікатор генерується заново саме після логіну — новим випадковим значенням із високою ентропією, тобто досить довгим і непередбачуваним, щоб його не вгадали; повторне використання ідентифікатора, який був до входу, — ознака вразливості (та сама фіксація сесії);
- ідентифікатор не передається в URL;
- живе він у захищеній куці —
Secure+HttpOnly; для суто автентифікаційних кукі придатний іSameSite=Strict: на сторінки за логіном користувач і так переходить кліками всередині сайту, а не за посиланням із чужого; - сесія стає недійсною після виходу, після простою і після абсолютного — граничного часу життя незалежно від активності;
- сервер ніколи не приймає ідентифікатор, якого сам не генерував.
OWASP (Open Worldwide Application Security Project) — міжнародна некомерційна спільнота фахівців із безпеки застосунків; її перелік найпоширеніших — загальновизнаний орієнтир галузі. Цей перелік описує і сценарій, від якого захищає Secure: даунгрейд — примусовий перехід із HTTPS на незашифрований HTTP — і перехоплення сесійної кукі.
Альтернатива серверному запису — токен, у який стан зашитий цілком: сервер окремого запису не тримає. Типовий приклад — JWT (JSON Web Token); JSON тут — просто текстовий формат запису структурованих даних, у ньому токен тримає свій вміст. Токен кладуть або в кукі, або у сховище браузера. У другому разі він не їде на сервер автоматично: його мусить явно додати в заголовок Authorization код застосунку.
Що обрати — залежить від того, де потрібен стан. Стан на сервері можна відкликати миттєво. Стан у токені робить самодостатнім кожен сервер за балансувальником: будь-який із них перевірить токен, не звертаючись до спільного сховища сесій. Зате достроково відкликати такий токен важко.
Для тестів це розвилка стратегії логіну. — ізольований «профіль» зі своїми кукі й сховищем, у якому інструмент автотестів відкриває сторінки; докладніше — у розділі про тести. Сесію на куці такий контекст переносить сам, бо кукі — його частина. А для токена доведеться відтворювати і сховище, і заголовок. Формати й потоки — JWT, OAuth, коди 401/403 — розбирає наступна глава «Автентифікація та авторизація». Нам тут досить розвилки «де живе стан».
Наша практика (не канон). Імена кукі часто підказують стек: JSESSIONID — Java, PHPSESSID — PHP, connect.sid — Express, sessionid — Django. Орієнтир зручний на незнайомому продукті, але переліку в джерелах немає.
Сховища браузера: дані, які нікуди не їдуть
Не все в нашому застосунку — сесія. Продукт хоче зберігати дві речі. Першу — чернетку коментаря — між перезавантаженнями сторінки. Другу — поточний крок покрокової форми оформлення замовлення (кроки «Доставка → Оплата → Підтвердження») — лише в межах вкладки. Тягати це кукою з кожним запитом безглуздо: серверу ці дані не потрібні. Для такого є Web Storage — пари «ключ-значення», доступні через обʼєкт Storage: window.localStorage і window.sessionStorage. API у них однаковий. Різняться область дії і час життя — і саме ця різниця вирішує, що переживе новий контекст у тесті:
| Характеристика | localStorage (чернетка) | sessionStorage (крок оформлення) |
|---|---|---|
| Область | Походження | Походження і вкладка |
| Час життя | Переживає перезапуск браузера | Закриття вкладки знищує дані |
| Спільність між вкладками | Так, у межах походження | Ні, у кожної вкладки свій |
| Доступ із JS | Завжди — аналога HttpOnly немає | Завжди |
Обидва сховища синхронні: поки триває запис чи читання, решта JavaScript на сторінці чекає. Тому для великих обсягів чи чутливого до швидкодії коду документація радить асинхронну альтернативу — IndexedDB, про неї нижче.
Значення сховище тримає як рядки тексту. Тому обʼєкт перед записом перетворюють на текст у форматі JSON, а при читанні розбирають назад в обʼєкт — це перетворення називають серіалізацією. У JavaScript його роблять функції JSON.stringify (запис) і JSON.parse (читання).
Про зміну сховища браузер сповіщає подією storage: її можуть слухати інші відкриті вкладки того самого сайту, щоб помітити зміну.
Квоти — обмеження на обсяг даних — описані в окремому документі, і формулювання там важливі. Стеля Web Storage — 10 МіБ у всіх браузерах. МіБ — мебібайт: майже звичний мегабайт, просто документація рахує обсяг степенями двійки (1 МіБ = 1024 × 1024 байтів).
У межах цієї стелі кожне сховище має свою частку: до 5 МіБ localStorage і до 5 МіБ sessionStorage на походження. «До» — верхня межа, не гарантія.
І остання деталь: квота рахується на все походження. Кілька застосунків під різними шляхами (/site1/ і /site2/) — за означенням глави одне походження, тому квота в них спільна.
Перевищення квоти дає помилку QuotaExceededError. Документація радить коду, який пише у сховище, бути готовим до неї — перехоплювати через try...catch, конструкцію JavaScript для обробки помилок; тестувальнику досить знати, що переповнення — очікувана ситуація, а не аварія. Скільки місця доступно і скільки вже зайнято, показує метод navigator.storage.estimate() — він віддає оцінку, а не точну цифру.
Типовий режим зберігання — best-effort («як вийде»): дані живуть, поки є місце й користувач їх не стер. Походження може попросити режим persistent — «не витісняти». У приватному режимі квоти бувають інші, дані зазвичай стираються після виходу, а localStorage поводиться як sessionStorage.
Коли даних більше — застосунки беруть IndexedDB — повноцінну базу даних просто в браузері. Привʼязана вона так само до походження, а від Web Storage відрізняється трьома речами:
- зберігає обʼєкти без перетворення на рядок і приймає бінарні дані — «сирі» байти на кшталт зображень чи файлів;
- операції йдуть : група змін або застосовується цілком, або не застосовується зовсім;
- API асинхронний: на відміну від Web Storage, запис і читання не зупиняють решту коду сторінки.
Квоти тут іншого порядку: не мегабайти, а частки всього диска, у кожного рушія свої. І рахуються вони від повного розміру диска, а не від вільного місця. Причина — фінгерпринтинг (fingerprinting): розпізнавання конкретного користувача за технічними дрібницями його системи. Вільне місце в кожного своє, тож квота, порахована від нього, виказувала б конкретну машину — і була б такою дрібницею. Тож фактично дійти до квоти може й не вдатися — місце скінчиться раніше.
При нестачі місця браузер витісняє дані за принципом LRU (least recently used — «найдавніше використане»): першим — походження, яким найдавніше користувалися; persistent-сховища пропускаються. Де це все живе як підсистема браузера — глава «Архітектура браузера й рендеринг».
Головний водорозділ між кукою і сховищем — не обсяг, а автовідправка. Кукі браузер додає до відповідних запитів сам. Web Storage та IndexedDB читає лише код сторінки: щоб дані дісталися сервера, застосунок мусить покласти їх у запит явно — у тіло або в заголовок на кшталт Authorization: Bearer …. Звідси безпековий наслідок. Аналога HttpOnly у сховищ немає: доступ має будь-який скрипт сторінки. Тож при XSS чужий скрипт прочитає токен із localStorage так само легко, як свій код, — а значення HttpOnly-кукі за тих самих умов лишиться для нього закритим. Вибір місця для токена — це вибір, яким шляхом атакувальник зможе до нього дістатися (такий шлях називають ), а не питання смаку.
І ще дві межі сховищ, окремі від автовідправки:
- сторонній скрипт, підключений тегом
<script>, виконується в контексті сторінки-вбудовника — і пише в її сховище, а не у своє; - вбудований чужий
<iframe>не отримає Web Storage, якщо користувач вимкнув сторонні кукі.
Наша практика (не канон). Пласке правило «localStorage, sessionStorage та IndexedDB ніколи не їдуть на сервер самі» формулюємо ми. Джерела кажуть те саме, але з іншого боку: сховище доступне скриптам походження, а на сервер дані потрапляють лише тоді, коли їх туди поклав застосунок.
Походження: чому сховище і кукі розділені по-різному
У таблиці сховищ областю стояло «походження» — і це не той самий кордон, що в кукі. Походження (origin) — трійка «схема + хост + порт». Дві адреси мають одне походження лише тоді, коли збігаються всі три складові. Шлях і параметри запиту в трійку не входять. А от піддомен — уже інший хост, тобто інше походження. Саме ця межа розділяє сховища.
І саме цю межу охороняє (same-origin policy) — вбудована поведінка браузера. Вона обмежує читання даних чужого походження скриптом, а не саме звернення до ресурсу: запит на чужий домен браузер відправить і відповідь отримає, але скрипту сторінки її не покаже.
Порівняйте кордони на нашому прикладі — розбіжність між ними і є джерело «дивних» багів:
app.example.comіapi.example.com— різні походження (окремі сховища), але один сайт (спільні правилаSameSite). Що для сховища чужина, для кукі — своє.http://localhost:3000іhttp://localhost:4000— різні походження, але один сайт: порт входить у походження й не входить у межу сайту. Ба більше, кукі не ізолюються за портом узагалі: що поставив сервіс на:3000, прочитає й сервіс на:4000. Межа довіри кукі — домен, а не застосунок. Тож симптом «сесія губиться між портами» треба пояснювати чимось іншим.
Останній кордон живе в <iframe>. У нього є атрибут sandbox: ним сторінка-вбудовник саджає вбудований документ у — режим з урізаними правами, де окремі можливості треба вмикати явно.
Один із таких дозволів — allow-same-origin: він лишає документу його рідне походження. Без нього фрейм отримує непрозоре походження — унікальне, що не збігається ні з чиїм, — а отже, лишається і без document.cookie, і без localStorage. Тож «сесія у фреймі не працює» — привід перевірити атрибут sandbox: це окрема від SameSite причина.
Повний виклад походження, політики одного походження й CORS — у главі «CORS і політика одного походження».
Що з цього видно в DevTools
Прапорці кукі й факт її відправки перевіряють у різних місцях, і плутанина між цими місцями коштує годин.
- Application → Storage → Cookies із вибором походження — таблиця збережених кукі з колонками
Domain,Path,Expires / Max-Age,HttpOnly,Secure,SameSite; у сесійної кукі в колонці часу життя завжди стоїтьSession. Усі поля, крімSize, редаговані; кукі з некоректними значеннями підсвічуються червоним; фільтр шукає лише заNameіValue. - Network → запит → вкладка Cookies — що реально поїхало саме з цим запитом. Дивитися треба тут, а не у вкладці Headers: збережена і відправлена кукі — різні факти, у чому ми переконалися ще на дефолтному
Path. - Відкинуте
Set-Cookieне мовчить: фільтрBlocked response cookiesзбирає запити з відкинутими кукі; причина — під іконкою info на вкладці Cookies, а поруч із самим запитом стоїть попереджувальна іконка. - Пастка «Provisional headers are shown»: цей напис означає, що DevTools показує не справжні заголовки. Або запит віддано з локального кешу, який заголовків не зберігає, або DevTools показує лише часткові заголовки з міркувань безпеки. «Заголовка немає у Headers» ≠ «браузер його не надсилав».
- HAR очищений від секретів за замовчуванням. HAR (HTTP Archive) — файл, у який DevTools експортує весь мережевий журнал вкладки Network. В експорті немає
Cookie,Set-CookieйAuthorization— тож його безпечно чіпляти до тікета.
Повний розбір панелі — у главі «DevTools: вкладка Network і дебаг».
Стан у тестах: кукі й сховище в Playwright
Усе вивчене — атрибути, сесія, сховища — сходиться в автотестах. UI-тести виконує Playwright — інструмент автотестів, який керує браузером замість людини. Проходити форму логіну в кожному тесті повільно й крихко: найшвидший логін той, що виконався один раз.
Механіка стандартна. «Проєкт» у Playwright — це група тестів зі своїми налаштуваннями; логін виносять в окремий службовий тест — setup-проєкт. Він виконується першим, логіниться один раз і зберігає (storage state) — файл із кукі й сховищем, у прикладі нижче це playwright/.auth/user.json. Далі в главі звемо цей файл знімком стану: знімок тут — копія даних на момент збереження, не . Решта тестів стартують уже з цим файлом — залогіненими.
// auth.setup.ts — один прохід логіну на весь прогін
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page, context }) => {
await page.goto('https://app.example.com/login');
// process.env.USER_EMAIL — змінна середовища: логін тримають поза кодом
await page.getByLabel('Email').fill(process.env.USER_EMAIL!);
// знак оклику — підказка мові TypeScript, якою написано тест: «значення тут точно є»
await page.getByLabel('Password').fill(process.env.USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// логін ставить кукі через кілька редиректів: без очікування
// кінцевого URL знімок застане проміжний стан
await page.waitForURL('**/dashboard');
await context.storageState({ path: 'playwright/.auth/user.json' });
});
Хто не знає складу знімка, годинами шукає не там: тест упав, чернетка коментаря на місці, а крок оформлення — з покрокової форми з розділу про сховища — зник, і з коду тесту не видно чому. Тому склад — явно:
- Кукі — потрапляють.
localStorage— потрапляє.- IndexedDB — лише з явною опцією
indexedDB: true. sessionStorage— ніколи: API для нього немає.
Саме тому крок оформлення з нашого прикладу і зник: він жив у sessionStorage, а чернетка — у localStorage, тож у новий контекст переїхала лише вона. Крок оформлення відновлюють вручну — через browserContext.addInitScript(). Це скрипт, який Playwright виконує на кожній новій сторінці ще до коду самої сторінки: він розкладає у сховище збережені заздалегідь дані у текстовому вигляді. Офіційний приклад цього скрипту в документації перед записом перевіряє window.location.hostname — властивість, якою скрипт читає хост сторінки, де він виконується. Дані він розкладає лише на очікуваному хості. Це той самий запобіжник, що й у прикладі нижче: знімок, знятий з одного хоста, не повинен потрапити у сховище іншого.
Ця схема вміє ламатися тихо. Приклад: setup залогінився на стенді https://app.example.com і зберіг user.json, а потім прогін перевели на новий стенд https://stage.example.com. Setup зелений, файл на місці, всередині — кукі session_id=abc123def456, але всі тести падають на формі логіну: сервер поводиться так, ніби бачить нас уперше. Розбираємо за алгоритмом, який працює для будь-якого «розлогіненого» тесту:
- Чи є кукі в контексті?
context.cookies()показує: є —{ name: 'session_id', value: 'abc123def456', domain: 'app.example.com', path: '/', ... }. Отже, файл підхопився, і «стан не завантажився» відпадає. - Чи поїхала вона із запитом? Вкладка Cookies першого запиту прогону —
GET https://stage.example.com/dashboard— порожня. Ось і розрив: збережена в браузері кукі є, у запиті її немає. - Звірити атрибути з запитом. У кукі
domain: 'app.example.com'— а ми памʼятаємо рішення з розділу про атрибути: кукі host-only. Хост запиту —stage.example.com. Браузер робить рівно те, що його попросили: не віддає кукі нікому, крімapp.example.com.
Сервер ні до чого, Playwright ні до чого — файл стану просто знятий з іншого стенду.
А чому setup зелений? Бо в ньому стояла «оптимізація»: якщо user.json існує — логін пропустити. Інструмент не відстежує, чи протух файл стану, — це обовʼязок автора — а такий guard, тобто перевірка-запобіжник «файл існує — логін пропустити», зробив файл вічним.
Обидва виправлення зводяться до одного результату: знімок завжди свіжий і з правильного стенду. Для цього стан генерують проти того стенду, на який ходить прогін, а файл кладуть у теку виводу, яку чистять перед прогоном. Ключова думка діаграми: спершу три перевірки — і лише потім гіпотези про бекенд.
Решта обовʼязків навколо стану — списком, кожен вартий окремої перевірки:
- Файл — живий секрет. У ньому справжні кукі. Файл не зберігають у спільному репозиторії коду — тека внесена в список ігнорованого (
.gitignore); чому це важливо, пояснить глава про Git. Артефакт із живою сесією виникає за будь-якої стратегії логіну. - Ролі й паралельність. Кілька ролей — кілька файлів стану. Дві ролі в одному тесті — два контексти. Якщо тести змінюють серверний стан — окремий акаунт на кожен : так називають паралельні процеси, які виконують тести одночасно. Спільний акаунт для них — джерело гонок: два тести одночасно міняють ті самі дані, і результат залежить від того, хто встиг першим.
- Логін без UI. Стан взаємозамінний між контекстом браузера й контекстом API-запитів — це такий самий ізольований профіль зі своїми кукі, тільки для запитів без браузера. Тож офіційний шлях — залогінитися і створити контекст, у якому кукі вже є.
- Кукі кладуть і чистять на рівні контексту. Кладуть через
addCookies, чистять черезclearCookies— це команди самого контексту браузера, вони працюють повз сторінку.HttpOnly-кукі досяжні лише на рівні контексту. Зсередини сторінки такої кукі не видно: командаpage.evaluate, яка виконує JavaScript прямо на сторінці, побачить те саме, що й будь-який скрипт, — порожньо. - У Selenium кукі — вбудована частина WebDriver-API. WebDriver — стандартний протокол керування браузером, поверх якого побудований Selenium; набір полів кукі обмежений його специфікацією. Додати кукі можна лише на її домені: спершу
get()(відкрити URL — так у Selenium називається перехід на сторінку), потім кукі. - Нюанс UI-режиму. UI-режим — це графічна оболонка Playwright, де тести запускають кнопкою і переглядають покроково. Setup у ньому за замовчуванням не виконується.
Ізоляцію ж між тестами дає сама модель контекстів: кожен тест отримує свіжий контекст браузера — еквівалент нового профілю майже без накладних витрат. Тому чистий стан не «прибирають» — його дає новий контекст. Покладатися на це можна, поки стан не шерять свідомо. Спільний файл стану, спільний акаунт чи дані в базі знову звʼязують тести між собою — і межі доводиться проводити руками.
Наша практика (не канон). Повний перелік причин «розлогіненого» тесту, з якого зібраний алгоритм:
- стан не підхопився;
- сесія жила в
sessionStorageчи IndexedDB, яких знімок не покрив; - кукі була сесійною й не пережила перезапуск браузера;
- сесія згасла за часом життя;
- невірні
Domain/Path; - спільний акаунт убили паралельні воркери.
І дві суміжні тези. Симптом протікання стану — «тести зелені поодинці, червоні в наборі». А клієнтська чистка не прибирає серверного стану: записи під спільним акаунтом наступний тест побачить і з чистим сховищем.
Типові помилки
- «Сервер віддає
Set-Cookie, а кукі в браузері немає — баг бекенду». НасправдіSameSite=NoneбезSecureбраузер відкидає цілком; дивитися треба на комбінацію атрибутів у відповіді. - «Кукі видно в DevTools — отже, вона поїхала із запитом». Насправді збереження і відправка — різні факти. Дефолтний чи завузький
Path, host-onlyDomainабоdomainвaddCookiesне з тим хостом лишають кукі збереженою в браузері, але не пускають у запит. Що поїхало насправді — вкладка Cookies запиту. - «
document.cookieне бачить кукі — щось зламалося». Насправді так виглядає справнийHttpOnly: скрипт отримує порожній рядок, а читати кукі треба на рівні контексту браузера. - «Прапорець у коді стоїть — значить, він і у відповіді». Насправді одруківка в назві атрибута кукі не ламає: браузер мовчки проігнорує нерозпізнане слово й лишить кукі без захисту. Видно це лише в
Set-Cookie. - «Локально
Secure-кукі працює — і на стенді працюватиме». Насправдіlocalhost— виняток. На стенді без TLS та сама кукі не поїде, і виглядає це як розлогінений користувач, а не як дефект. - «Стан підхопився, а користувач розлогінений — інструмент бреше». Насправді
sessionStorageу знімок не потрапляє ніколи, IndexedDB — лише за явною опцією; те, що там жило, відновлюють окремо. - «Почистили кукі й
localStorage— тести ізольовані». Насправді клієнтська чистка не чіпає серверного стану: записи під спільним акаунтом побачить наступний тест. - «
app.example.comіapi.example.com— різні сайти, кукі між ними не поїде». Насправді для кукі це один сайт; доставку вирішуєDomain, а неSameSite.
Підсумок
«Ви залогінені» — фізично один запис у конкретному місці, найчастіше кука з ключем сесії, а рішення «слати чи ні» ухвалює браузер — за правилами-атрибутами, які задав сам застосунок. Інваріанти, які переживуть будь-яку версію будь-якого браузера:
- Доказ «це знову я» їде в кожному запиті заново. Кукі додає браузер сам, токен — лише код застосунку (розділ «Чому сервер не памʼятає нас між запитами»).
- Долю кукі вирішують атрибути на боці браузера — і назад вони не їдуть. Із заголовка
Cookieсервер не бачить ні часу життя, ні прапорців; що браузер зберіг насправді — видно лише вSet-Cookie(розділи «Кукі» й «Атрибути»). - Межа кукі — домен, межа сховища — походження, і лише
SameSiteрахує сайт. Кукі не ізолюються ні за портом, ні за застосунком — межа їхньої довіри домен. Походження — «схема + хост + порт». Сайт дляSameSite— «схема + реєстрований домен», без порту. Більшість «дивних» розбіжностей між піддоменами, портами і схемами пояснюється різницею цих кордонів (розділи «SameSite» і «Походження»). - Головна різниця кукі й сховища — автовідправка, а не обсяг. Web Storage та IndexedDB нікуди не їдуть самі — і не мають аналога
HttpOnly(розділ «Сховища браузера»). - Ізоляція тестів — це клієнт і сервер разом. Знімок стану покриває не все, протухає мовчки й містить живі секрети; серверного стану він не чистить (розділ «Стан у тестах»).
Окремо — чекліст на випадок «тест бачить користувача розлогіненим»:
context.cookies()(або Application → Cookies): кукі взагалі є?- Вкладка Cookies проблемного запиту в Network: кукі поїхала?
- Розрив між 1 і 2 → звірити
Domain/Path/Secure/SameSiteз хостом, шляхом і схемою запиту. - Прапорці й одруківки перевіряти в самому
Set-Cookieвідповіді логіну. - Сесія жила не в куці?
sessionStorageта IndexedDB у знімок за замовчуванням не потрапляють. - Стан знято з того самого стенду, на який ходить прогін? Файл стану — у теку, що чиститься.
- І лише після всього цього — гіпотези про бекенд і серверний стан.
У глави лишився один борг, і він виріс просто з нашої кукі. Ми зробили session_id довгим, Secure, HttpOnly. Але сама зручність «браузер додає кукі сам» має зворотний бік: браузер додасть її й до запиту, який ініціювала чужа сторінка. Кукі доводить, що запит іде з вашого браузера, — а не що його хотіли ви. SameSite=Lax тут лише глибина оборони. Як ця атака працює і що її зупиняє — а заразом JWT, OAuth і коди 401/403 — наступна глава, «Автентифікація та авторизація». А як стан протікає між тестами й чому набір червоніє лише разом — глава «Практичні сценарії AQA: флак і синхронізація».
Можливі питання
- «Що таке cookie і навіщо вони, якщо HTTP без стану?» Чекають звʼязку «протокол не памʼятає → доказ несе кожен запит». Сильна відповідь додає, що рішення про відправку ухвалює браузер за атрибутами.
- «Чим сесійна кукі відрізняється від постійної?» Перевіряють знання
Expires/Max-Age; бали додає застереження про відновлення вкладок, через яке «закрив браузер — розлогінився» справджується не завжди. - «Навіщо
HttpOnlyі що він НЕ робить?» Слабка відповідь — «захищає від XSS». Сильна — «закриває читання значення скриптом, але не блокує ні XSS, ні відправку кукі із запитом, який ініціював JavaScript». - «Поясніть
SameSite:Strict,Lax,None». Дивляться, чи знає кандидат про обовʼязковийSecureдляNoneі про те, що дефолтнийLaxдозвільніший за явний. - «Що таке "той самий сайт" і чи є піддомени same-site?» Відповідь: сайт — схема плюс реєстрований домен, порт не рахується; піддомени — один сайт, але різні походження, а
httpпротиhttps— різні сайти. - «Чим
localStorageвідрізняється відsessionStorageі від cookie?» Чекають три осі: час життя, область (походження проти вкладки) і автовідправку на сервер. - «Де зберігати токен — у
localStorageчи в cookie?» Перевіряють розуміння, що це вибір вектора атаки, а не смаку: сховище доступне будь-якому скрипту,HttpOnly-кукі браузер підставляє сам — і тому вона вразлива вже до CSRF. - «Наступний тест бачить користувача розлогіненим. Ваші дії?» Найпрактичніше питання глави. Сильна відповідь дає порядок: що є в контексті → що поїхало в заголовку
Cookie→ звірка атрибутів із хостом, шляхом і схемою — і лише потім гіпотези про бекенд.
Джерела
Чому сервер не памʼятає нас між запитами
- RFC 9110 — HTTP Semantics — HTTP як протокол без стану: семантика запиту зрозуміла ізольовано, звідси масштабованість.
- MDN — An overview of HTTP — безстановий протокол проти застосунку зі станом.
- RFC 6265 — HTTP State Management Mechanism — механізм, яким сервер тримає стан на боці клієнта:
Set-Cookie,Cookieі рішення браузера за атрибутами. - RFC 6750 — The OAuth 2.0 Authorization Framework: Bearer Token Usage — форма заголовка
Authorizationзі схемоюBearer.
Кукі: сервер пропонує, браузер вирішує
- RFC 6265 — HTTP State Management Mechanism — обмін
Set-Cookie/Cookie, асиметрія атрибутів, алгоритмdefault-path, ліміти §6.1, порядок кукі й витіснення. - MDN — Using HTTP cookies — означення кукі, дефолти без атрибутів, запис і читання кукі з клієнтського коду через
document.cookie.
Атрибути: правила доставки, які виконує браузер
- RFC 6265 — HTTP State Management Mechanism —
Domain,Path,Expires,Max-Age,Secure,HttpOnly, ігнорування нерозпізнаних атрибутів, ключ кукі, видалення, перезаписSecure-кукі активним атакувальником. - MDN — Using HTTP cookies —
Secureз виняткомlocalhost,Domainяк розширення області, сесійна проти постійної кукі й відновлення вкладок. - MDN — Set-Cookie — префікси імен та їхня межа,
Set-Cookieяк заборонений для читання заголовок,HttpOnly-кукі в запитах з JavaScript. - OWASP — HttpOnly — що саме робить прапорець і чому читання такої кукі дає порожній рядок.
- OWASP — SecureCookieAttribute — пастка середовищ без TLS із завжди виставленим
Secureі перевірка прапорця у відповіді, бо TLS буває офлоуднутий на балансер.
SameSite: «той самий сайт» — не той самий хост
- MDN — Using HTTP cookies — три значення
SameSite, дефолтLax, вимогаSecureдляNone, крос-сайтовість через реєстрований домен та/або схему. - MDN — Set-Cookie — режим
Lax-allowing-unsafeі кукі вPOSTпротягом двох хвилин. - IETF Internet-Draft — Cookies: HTTP State Management Mechanism (rfc6265bis-22) — відкидання
SameSite=NoneбезSecure, невідоме значення якLax, реєстрований домен через публічний суфікс. - web.dev — Same-site and same-origin — мотив додавання схеми в означення сайту: щоб HTTP не був слабким каналом.
- WHATWG HTML Standard — Origins, Sites, Sandboxing — критерій same-site: пара «схема + реєстрований домен», порт ігнорується.
- WHATWG URL Standard — чому публічний суфікс і реєстрований домен не є жорсткою межею безпеки.
Сторонні кукі: вирішує браузер, а не атрибути
- MDN — Third-party cookies — сторонність як властивість пари «кукі × сторінка», дефолти рушіїв, Total Cookie Protection, Storage Access API.
- WebKit — Intelligent Tracking Prevention — on-device модель, три сигнали, режими за давністю взаємодії, партиціювання й вичищення.
- IETF Internet-Draft — Cookies: HTTP State Management Mechanism (rfc6265bis-22) — «сайт для кукі» береться з верхньорівневого походження, для вкладених документів перевіряється весь ланцюг предків.
Сесія на сервері: у браузері лише ключ
- RFC 6265 — HTTP State Management Mechanism — обмін «логін → запис сесії → ідентифікатор кукою → впізнавання за наступними запитами».
- MDN — Using HTTP cookies — вимоги до ідентифікатора сесії й
SameSite=Strictдля автентифікаційних кукі. - OWASP — Session Management Cheat Sheet — тимчасова еквівалентність найсильнішому методу автентифікації; сервер не приймає ідентифікатора, якого сам не генерував.
- OWASP — Cookie Theft Mitigation Cheat Sheet — bearer-природа: сервер перевіряє лише валідність значення, не питаючи, хто надіслав; крадіжка сесійної кукі рівна крадіжці облікових даних.
- OWASP Top 10 2025 — A07 Authentication Failures — новий випадковий ідентифікатор із високою ентропією після логіну, не в URL, захищена кукі, інвалідація після виходу, простою й абсолютного таймауту; повторне використання ідентифікатора після логіну як ознака вразливості.
- OWASP Top 10 2025 — A04 Cryptographic Failures — сценарій даунгрейду HTTPS→HTTP із крадіжкою сесійної кукі.
- RFC 7519 — JSON Web Token (JWT) — токен як альтернатива серверному запису сесії.
- MDN — Authorization header — передавання токена заголовком запиту.
Сховища браузера: дані, які нікуди не їдуть
- MDN — Web Storage API — область і час життя обох сховищ, синхронність, подія
storage, сторонній скрипт і вимкнені сторонні кукі, порада щодо IndexedDB. - MDN — Storage quotas and eviction criteria — 10 МіБ стелі, до 5 МіБ на походження,
QuotaExceededError,navigator.storage.estimate(), best-effort проти persistent, приватний режим, квоти великих сховищ від повного розміру диска, фінгерпринтинг і LRU. - W3C — Indexed Database API 3.0 — IndexedDB як транзакційна обʼєктна база з асинхронним API.
- MDN — JSON (JavaScript reference) — серіалізація обʼєктів через
JSON.stringify/JSON.parse. - MDN — Authorization header — заголовок, яким застосунок явно доставляє токен на сервер.
- MDN — Cross-site scripting (XSS) — чому доступне будь-якому скрипту сховище вразливіше за
HttpOnly-кукі. - RFC 6265 — HTTP State Management Mechanism —
HttpOnlyяк межа доступу скриптів до кукі.
Походження: чому сховище і кукі розділені по-різному
- MDN — Same-origin policy — походження як трійка, піддомен як інший хост, обмеження читання, а не звернення.
- MDN — Web Storage API — розділення
localStorageза походженням іsessionStorageза вкладкою й походженням. - WHATWG HTML Standard — Origins, Sites, Sandboxing — прапорець пісочниці й непрозоре походження, порт поза межею сайту.
- RFC 6265 — HTTP State Management Mechanism — кукі не ізолюються за портом.
- Chrome DevTools — View, add, edit, and delete cookies — панель Application → Storage → Cookies, колонки,
Session, редагування, підсвічування, фільтр. - Chrome DevTools — Network features reference — вкладка Cookies запиту, фільтр заблокованих кукі відповіді, provisional headers, санітизований HAR.
Стан у тестах: кукі й сховище в Playwright
- Playwright — Authentication — склад знімка стану, setup-проєкт, протухання й тека виводу, UI-режим, ролі й акаунт на воркер, відновлення
sessionStorage, файл стану як секрет, очікування кінцевого URL. - Playwright — Projects — залежність проєктів, якою setup виконується перед тестами.
- Playwright — API testing — взаємозамінність стану між контекстом браузера й контекстом API-запитів.
- Playwright — class BrowserContext —
addCookies,clearCookies,addInitScript, опціяindexedDBуstorageState. - Selenium — Working with cookies — кукі як частина WebDriver-API й вимога бути на домені кукі.
- Playwright — головна сторінка — свіжий контекст на кожен тест як механізм ізоляції без накладних витрат.
Що таке cookie і навіщо вони потрібні, якщо HTTP не тримає стану?
Cookie — це невеликий іменований запис, який сервер віддає браузеру, браузер його зберігає і далі сам підкладає до наступних запитів на той самий адресат. Потреба в такому механізмі виникає рівно тому, що протокол не бере на себе обовʼязку памʼятати клієнта: кожен запит читається окремо, тож доказ «це знову той самий користувач» доводиться прикладати щоразу заново. Механіка проста: у відповіді їде заголовок Set-Cookie з парою імʼя=значення та набором атрибутів, а далі рішення «слати чи ні» ухвалює браузер, звіряючи ці атрибути з адресою наступного запиту. Ключова деталь для сильної відповіді — код сторінки в цьому рішенні не бере участі взагалі. Альтернатива кукі — токен, який клієнтський код зберігає самостійно й вручну кладе в заголовок Authorization; сам він нікуди не поїде. Для тестувальника з цього випливає перше практичне правило: якщо стан носить кукі, достатньо перенести кукі, а якщо токен — доведеться відтворювати і сховище, і заголовок.
Що саме браузер повертає серверу в заголовку Cookie?
Лише пари імʼя=значення, зліплені в один заголовок. Атрибути назад не подорожують: сервер, який читає Cookie, не дізнається ні строку життя, ні Domain, ні Path, ні того, чи стояли Secure та HttpOnly. Асиметрія тут навмисна — атрибути адресовані браузеру, бо саме він виконує правила доставки, а серверу лишається сама пара. Порядок пар контрактом теж не є, тож серверна логіка «перша кукі — сесійна» буде крихкою. Практичний наслідок для QA: перевірити прапорці за запитом клієнта неможливо, їх видно тільки у відповіді, у Set-Cookie, — і саме там їх треба перевіряти. Додатковий нюанс: сенс конкретної кукі протокол не описує взагалі, це домовленість застосунку, тому «що означає ця кукі» зʼясовують у бекендерів, а не в специфікації.
Чим сесійна кукі відрізняється від постійної?
Наявністю явного строку життя — атрибутів Expires і Max-Age. Сесійна обходиться без обох і живе рівно доти, доки триває сесія браузера; саме такою зазвичай і роблять кукі з ідентифікатором сесії. Постійній достатньо одного з них — вона переживає рестарт і тримається до зазначеної точки в часі; на цьому й будують «запамʼятати мене». Expires задає граничну дату й час, Max-Age — кількість секунд від моменту встановлення, і за наявності обох виграє Max-Age. Бали на співбесіді додає застереження, на якому спотикаються тести: правило «закрив браузер — вийшов» ламається там, де браузер уміє повертати попередню сесію вкладок: разом із вкладками він тягне через рестарт і сесійні кукі. Тому сценарій «перевіряємо вихід перезапуском браузера» без перевірки самого стору легко дає хибнозелений результат.
Domain звужує область кукі чи розширює її?
Розширює — і це протилежне тому, що підказує інтуїція. Без цього атрибута кукі виходить host-only: вона прикріплена рівно до того хоста, який її поставив, і на піддомени не піде. Щойно Domain зʼявився, кукі стає доступною і всім піддоменам указаного домену, тобто атрибут робить правило менш обмежувальним, а не більш. Межа в цієї щедрості одна: захопити чужий домен не вийде — якщо Domain не покриває сервер, з якого кукі прийшла, браузер її просто відкине. Для тестів звідси два наслідки. Перший: кукі, поставлена з Domain на кореневий домен, поїде й на піддомени, і це треба враховувати, коли середовища живуть поруч. Другий: у методі додавання кукі в domain треба вказувати той, що реально збігається з хостом застосунку, інакше кукі осяде в сторі й у запит не потрапить.
Навіщо потрібен HttpOnly і чого він НЕ робить?
Він відрізає скриптам доступ до значення кукі: спроба прочитати таку кукі через document.cookie повертає порожній рядок. Слабка відповідь на це питання — «захищає від XSS»; сильна одразу окреслює межу. По-перше, не блокує сам XSS і не заважає внесеному скрипту виконуватися. По-друге, запит, породжений скриптом, усе одно повезе таку кукі, — тобто чужий код успішно діє від імені користувача, не маючи доступу до самого значення. Тож HttpOnly закриває витік значення, а не саму . Для автотестів це переходить у типову хибну тривогу: якщо тест намагається дістати сесійну кукі скриптом на сторінці й отримує порожнечу, зламався не застосунок: дістати таку кукі можна на рівні контексту браузера, де вона видима.
Що дає Secure і чому кукі «працює локально, а на стенді ні»?
Secure каже браузеру не передавати кукі незахищеним каналом. Це рішення виконує саме браузер, і воно стосується виключно каналу: від підсадження кукі прапорець не рятує, бо активний мережевий здатен перезаписати навіть Secure-кукі з незахищеного зʼєднання. Найчастіша практична пастка ховається у винятку для localhost: локально кукі з Secure спокійно ходить по HTTP, тому на машині розробника все зелене. Щойно той самий опиняється на стенді, який відкритий по звичайному HTTP, кукі перестає їздити — і виглядає це як розлогінений користувач або як «бекенд не ставить сесію». Це не дефект застосунку, а конфігурація середовища, і саме такий діагноз від кандидата чекають почути.
Поясніть SameSite: Strict, Lax, None.
Атрибут вирішує, чи підкладати кукі в запити, що починаються з чужого сайту. Strict — найсуворіший: кукі їде лише тоді, коли запит іде з того самого сайту. Lax додатково пропускає верхньорівневу навігацію , тобто перехід за посиланням кукі не губить. None дозволяє міжсайтові запити, але обовʼязково в парі з Secure — і ця вимога категорична: без Secure браузер відкидає кукі цілком, а в тестах симптом читається так: у відповіді заголовок є, а в сторі порожньо. Дві деталі відрізняють сильну відповідь. Перша: якщо атрибута немає взагалі, кукі трактується як Lax, але цей дефолтний варіант дозвільніший за явно записаний — свіжовстановлена кукі потрапляє і в POST, якщо від її встановлення минуло не більше двох хвилин. Друга: невідоме значення атрибута не ігнорується, а теж підпадає під Lax, тож одруківка дає не свободу, а лаксові обмеження.
Що таке «той самий сайт» і чи є піддомени same-site?
Сайт — це не хост. Межу рахують за парою «схема + реєстрований домен», де — плюс одна мітка ліворуч; порт у цю межу не входить взагалі. Звідси три відповіді, які й перевіряють: app.example.com і api.example.com — один сайт, хоч в них різні; https://site.com і https://site.com:8443 — теж один сайт, бо порт не рахується; а от http://example.com і https://example.com — уже різні сайти, бо схема в межу входить. Кандидати найчастіше плутають саме останнє: домен той самий, а SameSite розводить їх по різні боки. Варто додати й обережність, яку рекомендує сама вебплатформа: список публічних суфіксів різниться між клієнтами, тому покладатися на неї як на надійний барʼєр не варто — перевірки за походженням дають більше певності.
Чим localStorage відрізняється від sessionStorage і від кукі?
Осей три: час життя, область і автовідправка. localStorage привʼязаний до походження, спільний для всіх вкладок цього походження й переживає перезапуск браузера. sessionStorage розділений і за походженням, і за вкладкою: у кожної вкладки свій, і закриття вкладки знищує все, що там лежало. API в них однаковий, обидва синхронні — запис і читання блокують решту JavaScript, — а значення зберігаються серіалізованими, тобто обʼєкти проходять через JSON.stringify і JSON.parse. А головний водорозділ проходить не між двома сховищами, а між сховищем і кукою, і це автовідправка: браузер додає кукі до відповідних запитів сам, а Web Storage читає лише код сторінки, тож на сервер ці дані потраплять тільки тоді, коли застосунок явно покладе їх у тіло або в заголовок. Звідси й розподіл ролей: серверну сесію носить кукі, а у сховищі лишають те, що далі клієнта нікуди не їде.
Що таке походження і як воно ділить сховище браузера?
Походження — це трійка «схема + хост + порт», і двома адресами одного походження вони будуть лише тоді, коли збігаються всі три складові; шлях, параметри й фрагмент до трійки не належать, а піддомен — це вже інший хост. Ця сама межа ділить і сховища: localStorage розкладений по походженнях, а sessionStorage — ще й по вкладках. при цьому ріже саме доступ скрипта до чужих даних, а не сам похід за ресурсом, і ця асиметрія пояснює чимало «дивної» поведінки. Найкорисніший для QA наслідок — розбіжність між межею сховища і межею кукі: порт враховується в походженні, а для кукі його ніби не існує. Тому два стенди на одному хості з портами 3000 і 4000 дістають різні localStorage, зате кукі в них одні на двох — і два паралельні прогони чудово перетруть одне одному сесію.
Серверна сесія проти токена: де живе стан і що з цього для тестів?
У класичній схемі браузер тримає не дані, а ключ: після входу сервер заводить у себе запис із повним станом — особа, ролі, вміст кошика — і повертає браузеру кукою довгий випадковий ідентифікатор. З кожним наступним запитом кукі повертається, сервер знаходить запис і впізнає користувача. У токенній схемі стан зашитий у сам токен, окремого запису сервер не тримає, а сам токен лежить у кукі або у сховищі й у другому випадку летить не автоматично, а вручну заголовком Authorization. Різниця в тому, де живе стан, і вона має ціну: серверний запис можна відкликати миттєво, а самодостатній токен достроково погасити важко. Для тестів це розвилка сценаріїв: у першому випадку достатньо перенести кукі, у другому доводиться відтворювати і вміст сховища, і заголовок. Ідентифікатор сесії при цьому роблять довгим і випадковим та закривають HttpOnly і Secure не з педантизму: після входу він тимчасово рівноцінний найсильнішому методу автентифікації, який застосунок узагалі використовує.
Де зберігати токен — у localStorage чи в кукі?
Правильна рамка відповіді — це вибір , а не вибір смаку. Токен у localStorage доступний будь-якому скрипту сторінки, тож будь-який XSS означає його викрадення; аналога HttpOnly у Web Storage немає за побудовою. Токен у HttpOnly-кукі скрипту недоступний, зате браузер підставляє його автоматично — і саме автоматичність відкриває міжсайтову підробку запиту, від якої закриваються SameSite та окремими токенами форми. Третій варіант — тримати токен лише в памʼяті сторінки: він не переживає перезавантаження, що незручно користувачу, але й красти нема звідки. Практичний висновок для співбесіди: назвіть обидва , а не «localStorage небезпечний», і додайте, що для тесту вибір теж не безплатний — кукі переноситься зі станом, а токен у сховищі доводиться відновлювати вручну.
У коді прапорець стоїть, а кукі виявилася незахищеною. Як таке можливо?
Найімовірніше — одруківка в назві атрибута. Нерозпізнаний атрибут браузер просто ігнорує, але саму кукі при цьому не відкидає, тож Secur замість Secure мовчки лишає кукі без захисту й нічого ніде не червоніє. Побачити це можна лише у відповіді, у Set-Cookie, і перевірити зі сторінки не вийде за побудовою: цей заголовок віднесений до заборонених для читання, тому скрипт його не отримає. Тому на прапорці ставлять там, де відповідь видно повністю, — на рівні мережевого перехоплення або API-клієнта. Окремий рубіж проти таких помилок — префікси імені: кукі з префіксом __Host- браузер прийме, тільки якщо вона Secure, прийшла по HTTPS, не має Domain і має кореневий Path; для __Secure- вимог дві — Secure і HTTPS. Інакше кукі летить у смітник. Застереження, яке варто озвучити: браузер без підтримки префіксів приймає такі кукі завжди, тож це посилення, а не гарантія.
Які обмеження за розміром мають кукі й Web Storage?
Для кукі специфікація задає браузерам не стелю, а підлогу, та ще й у необовʼязковій модальності: браузер має підтримувати щонайменше 4096 байтів на кукі — це сума довжин імені, значення й атрибутів — та щонайменше 50 кукі на домен і 3000 загалом. Конкретний браузер може дозволяти більше, тож жодного «рівно 4 КБ» тут немає. Із цих чисел ростуть дві вимоги: кукі має бути якнайменше і якнайменших, бо заголовок їде з кожним запитом, і покладатися на те, що кукі неодмінно повернеться, не можна — браузер має право витіснити будь-яку будь-коли. У Web Storage числа інші: стеля в усіх браузерах — 10 МіБ, і в її межах до 5 МіБ на походження для localStorage та стільки ж для sessionStorage. Слово «до» тут ключове: реальна квота різниться від браузера до браузера, рахують її на ціле походження навіть тоді, коли на ньому вміщається кілька сайтів, а в приватному режимі вона взагалі інша. Перевищення дає QuotaExceededError, тому запис радять загортати в try...catch, а оцінити зайняте й доступне місце можна через navigator.storage.estimate() — саме оцінку, не точну цифру.
Де в DevTools дивитися прапорці кукі, а де — факт її відправки?
Це різні місця, і плутанина тут коштує годин. Панель Application у розділі Storage показує стор кукі для обраного походження: там видно Domain, Path, строк життя, HttpOnly, Secure, SameSite, у сесійної кукі в колонці строку завжди стоїть Session, майже всі поля можна відредагувати, а некоректні значення підсвічуються. Але наявність кукі в цій таблиці ще нічого не каже про конкретний запит. Що реально поїхало, показує вкладка Cookies самого запиту в панелі Network — саме там, а не в Headers. Відкинуті кукі теж не мовчать: у мережевій панелі є фільтр запитів із заблокованими кукі відповіді, а причину блокування видно під іконкою інформації. Варто памʼятати ще одну пастку: попередження про неповні заголовки означає, що відповідь дістали з локального кешу, а він оригінальних заголовків не тримає; тож порожнеча у вкладці Headers ще не доводить, що браузер заголовка не слав. І приємна дрібниця для баг-репортів: експорт HAR за замовчуванням санітизований, кукі й Authorization у нього не потрапляють.
Що покриває збережений стан автентифікації в Playwright і чого він не покриває?
Збережений стан — це файл, у який один раз складають результат логіну, щоб решта тестів не проходила форму входу заново. До нього входять кукі, localStorage, IndexedDB і -автентифікація, а от sessionStorage лишається за бортом — API для його знімка просто немає, тож коли токен живе саме там, підставляти його доводиться руками. Стандартна механіка — окремий setup-проєкт, оголошений залежністю решти, тому він завжди виконується перед тестами. Разом зі станом приходять чотири обовʼязки. Протухання ніхто не відстежує, тому файл або видаляють самі, або пишуть у теку виводу проєкту, яку й так витирають на старті прогону; окремо варто памʼятати, що в UI-режимі setup сам собою не стартує. Файл містить справжні кукі, тобто це живий секрет — його не . Кілька ролей означають кілька файлів, а дві ролі всередині одного тесту — два контексти. І нарешті, логінитися можна без UI: стан однаково читається і контекстом браузера, і контекстом API-запитів, тож офіційний рецепт такий — пройти вхід запитами, а тоді підняти контекст із уже готовими кукі.
Наступний тест бачить користувача розлогіненим. Ваші дії?
Найпрактичніше питання теми, і сильна відповідь дає порядок дій, а не список гіпотез. Крок перший — подивитися, чи взагалі є потрібна кукі в контексті: якщо її немає, стан або не доїхав, або сесія трималася в sessionStorage, що лишається поза межами збереженого стану, або кукі була сесійною й не пережила рестарт. Крок другий — перевірити, чи поїхала кукі саме з тим запитом, у вкладці Cookies конкретного запиту: наявність у сторі й факт відправки — різні речі. Якщо кукі є в сторі, але в запит не потрапляє, майже завжди винні атрибути, і починати треба зі звірки Domain, Path, Secure й SameSite з реальним хостом і схемою стенда — типові винуватці це завузький Domain, Secure на HTTP-середовищі та SameSite, який ріже перехід із зовнішнього сайту. І лише коли кукі доїхала, гіпотези переїжджають на бекенд: сесія згасла за часом життя або спільний акаунт убили паралельні . Головне — не починати з бекенду: розбіжність «у сторі є, а в запиті немає» до нього не має жодного стосунку.
Тести зелені поодинці, але червоні в наборі. Що це означає і чи вистачить чистки сховища?
Такий симптом майже завжди означає протікання стану: попередній сценарій лишив по собі щось, чого наступний не очікує. Джерела два, і плутати їх дорого. Клієнтське протікання лікується ізоляцією контекстів: кожен тест отримує свіжий контекст браузера, еквівалентний новому профілю, тож чистий стан не треба прибирати — його дає сам контекст, а небезпека повертається лише там, де стан шерять свідомо, спільним файлом стану чи спільним акаунтом. Серверне протікання чисткою сховища не лікується взагалі: localStorage і кукі можна витерти дочиста, але записи, створені під спільним акаунтом, наступний тест однаково побачить, бо вони лежать у базі, а кукі на них лише вказувала. Тому правильна відповідь звучить як «ізоляція — це клієнт і сервер разом»: свіжий контекст плюс окремий акаунт на воркер там, де тести змінюють серверні дані.
Три кейси, у яких знання про кукі й сховище економить робочий день: триаж «розлогіненого» тесту за двома панелями DevTools, збережений стан у Playwright разом із діркою, про яку дізнаються найболючішим способом, і переїзд на нове середовище, де сесія зникає без жодної зміни в коді продукту.
Кейс 1. Тест бачить користувача розлогіненим
Симптом виглядає однаково в усіх командах: сетап відпрацював зелено, а перший же крок сценарію впирається у форму входу. Спокуса — одразу писати бекендерам. Правильний перший крок дешевший: зʼясувати, на якому з двох рубежів загубилася кукі — її взагалі немає в контексті чи вона є, але в запит не потрапила.
Це та розвилка, яку пропускають найчастіше: панель Application показує стор , а не зміст конкретного запиту, тож «кукі видно в DevTools» ще не означає «кукі поїхала».
| Що бачимо | Найімовірніша причина | Чим перевірити |
|---|---|---|
| Кукі немає в сторі взагалі | стан не підхопився або токен жив у sessionStorage, якого збережений стан не покриває | вміст файлу стану: чи є там потрібне імʼя |
Кукі немає, хоча відповідь несла Set-Cookie | комбінація атрибутів відкинута браузером — класика це SameSite=None без Secure | фільтр заблокованих кукі відповіді, іконка інформації з причиною |
| Кукі є в сторі, у запиті немає | завузький Domain або Path, чи domain, підставлений у контекст не тим, що реальний хост | звірити колонки стору з хостом і шляхом запиту |
| Кукі є, але зникла після рестарту | вона сесійна: у колонці строку життя стоїть Session | той самий стор до і після перезапуску |
| Кукі доїхала, сервер усе одно віддає екран логіну | сесія згасла за часом життя або її вбив паралельний воркер під спільним акаунтом | час створення стану, кількість воркерів на один акаунт |
Скрипт читає document.cookie і бачить порожній рядок | на кукі стоїть HttpOnly — це штатна поведінка | той самий запит на рівні контексту браузера |
Що дивитися і чому:
- Два місця, а не одне. Стор кукі в панелі Application відповідає на питання «що браузер зберіг», вкладка Cookies конкретного запиту — на питання «що поїхало». Діагноз дає саме розбіжність між ними, тож дивитися треба обидва, а не той, що ближче.
HttpOnly— не поломка. Порожній рядок у відповідь на читання кукі скриптом означає, що працює як задумано. Значення в такому разі беруть із , а не зі сторінки, і це різниця між «тест написаний не так» і «застосунок зламався».- Атрибути сервер не бачить. У заголовку
Cookieїдуть самі пари, тож просити бекенд «подивитися, які прапорці прийшли» безглуздо — прапорці лишилися вSet-Cookieна попередньому кроці. - Одруківка мовчить. Якщо в назві атрибута помилка, браузер його проігнорує, а кукі збереже без захисту. Ніде не червоніє, і єдине місце, де це видно, — сам заголовок відповіді.
Кейс 2. Збережений стан у Playwright: логін один раз і дірка в sessionStorage
Проходити форму входу в кожному тесті — це і повільно, і крихко: будь-яка зміна екрана логіну фарбує в червоне всю сюїту. Логін виносять в окремий setup-проєкт, який оголошують залежністю решти, і зберігають результат у файл.
// auth.setup.ts — один прохід логіну на весь прогін
import { test as setup } from '@playwright/test';
const STATE = 'playwright/.auth/user.json'; // тека під .gitignore: це живий секрет
setup('логін адміністратора', async ({ page, context }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// кукі часто ставиться в кінці ланцюга редиректів;
// знімок без цього очікування застане проміжний стан
await page.waitForURL('**/dashboard');
await context.storageState({ path: STATE });
});
Далі — місце, де витрачають найбільше часу. Файл стану везе кукі, localStorage та IndexedDB, але sessionStorage у нього не потрапляє взагалі. Якщо застосунок тримає токен саме там, тест стартує з бездоганно відновленим станом і порожнім токеном.
import { test } from '@playwright/test';
// sessionStorage збережений стан не покриває — відновлюємо руками
test.beforeEach(async ({ page }) => {
const session = JSON.parse(process.env.SESSION_SNAPSHOT!);
await page.addInitScript((data) => {
for (const [key, value] of Object.entries(data)) {
window.sessionStorage.setItem(key, String(value));
}
}, session);
});
Логін через UI взагалі не обовʼязковий: стан однаково читається обома контекстами — браузерним і тим, що шле API-запити, — тому офіційний рецепт такий: пройти вхід запитами й підняти контекст із уже готовими кукі.
import { test, expect, request as playwrightRequest } from '@playwright/test';
test('кошик читача порожній', async ({ browser }) => {
const api = await playwrightRequest.newContext({ baseURL: process.env.BASE_URL });
const res = await api.post('/api/session', {
data: { email: process.env.READER_EMAIL, password: process.env.READER_PASSWORD },
});
expect(res.status()).toBe(200);
// кукі сесії вже лежать у контексті API — переносимо їх у браузер
const context = await browser.newContext({ storageState: await api.storageState() });
const page = await context.newPage();
await page.goto('/cart');
await expect(page.getByText('Кошик порожній')).toBeVisible();
await context.close();
await api.dispose();
});
Що дивитися і чому:
- Протухання — ваш клопіт. Інструмент за строком життя файлу не стежить і про застарілий стан не попередить. Найдешевший захист — писати файл у теку виводу, яка чиститься перед кожним прогоном; і памʼятайте, що в UI-режимі setup сам собою не стартує, тож там стан легко буває вчорашнім.
- Файл стану — секрет, а не . У ньому справжні кукі, тобто хто дістав файл, той увійшов. Місце йому в теці під
.gitignore, а не в репозиторії поруч із тестами. - Ролі й паралельність. Кілька ролей — кілька файлів стану; дві ролі всередині одного сценарію — два контексти. Якщо тести змінюють серверні дані, потрібен окремий акаунт на : спільний акаунт дає гонки, які виглядають як випадкове розлогінення.
domainу підставленій кукі має збігатися з реальним хостом. Кукі, додана в контекст із неточним доменом, спокійно ляже в стор і так само спокійно не поїде в запит — а симптом буде той самий, що й у першому кейсі. У Selenium обмеження ще суворіше: кукі приймається тільки для домену, де браузер зараз відкритий.- Ізоляцію дає контекст, а не . Кожен тест отримує свіжий контекст, еквівалентний новому профілю, тож чистий старт не треба будувати руками. Небезпека лишається рівно там, де стан шерять свідомо: спільний файл, спільний акаунт, спільні дані в базі.
Кейс 3. Сюїта переїхала на нове середовище — і сесія зникла
Код продукту той самий, тести ті самі, змінилася лише адреса стенда. Половина сценаріїв червона, у кожному — екран входу. Це найтиповіший випадок, коли винен не застосунок, а те, як атрибути кукі накладаються на нову схему, хост і порт.
| Переїзд | Що ламається | Чому |
|---|---|---|
| З HTTPS на стенд по звичайному HTTP | кукі з Secure не їде взагалі | прапорець забороняє незахищений канал; локально це не видно, бо localhost — виняток |
З app.example.com на staging.example.com | кукі не доїжджає, якщо вона host-only | без Domain кукі прикріплена рівно до хоста, який її поставив |
| З порту 3000 на порт 4000 | localStorage порожній, а кукі раптом спільні | порт враховується в походженні, а для кукі його ніби немає |
| Повернення з зовнішнього SSO чи платіжки | сесія «губиться» на редіректі назад | міжсайтовий запит вимагає SameSite=None разом із Secure |
Вхід за посиланням із листа під SameSite=Strict | перший екран виглядає розлогіненим | навігацію з чужого сайту Strict кукі не пропускає — це очікувана поведінка |
| Два прогони на різних портах того самого хоста | прогони перетирають одне одному сесію | спільні кукі при роздільних сховищах |
Перевірити гіпотезу можна за хвилину, не чекаючи на бекенд.
import { test, expect } from '@playwright/test';
test('сервер ставить сесійну кукі з робочою комбінацією атрибутів', async ({ request }) => {
const res = await request.post('/api/session', {
data: { email: process.env.USER_EMAIL, password: process.env.USER_PASSWORD },
});
const raw = res.headersArray()
.filter((h) => h.name.toLowerCase() === 'set-cookie')
.map((h) => h.value)
.join('\n');
expect(raw, 'сесійної кукі немає у відповіді взагалі').toContain('session_id=');
expect(raw).toContain('HttpOnly');
// SameSite=None без Secure браузер відкине цілком — кукі не буде ніде
if (raw.includes('SameSite=None')) {
expect(raw, 'None без Secure — кукі буде відкинута').toContain('Secure');
}
});
Що дивитися і чому:
- «Сервер віддає
Set-Cookie, а кукі немає» — це не автоматично баг бекенду. Спочатку дивляться на комбінацію атрибутів і на схему середовища: у більшості таких випадків браузер відкинув кукі рівно за правилами. - Перевіряти прапорці треба у відповіді. Зі сторінки це неможливо за побудовою:
Set-Cookieвіднесений до заборонених для читання заголовків, тож живе на рівні API-клієнта або мережевого перехоплення. app.example.comіapi.example.com— один сайт, але різні походження. Тому «кукі між ними не поїде, боSameSite» — хибний діагноз: між ними вирішуєDomain. А різні походження означають, щоlocalStorageу них таки роздільний.- HAR можна чіпляти до тікета. Експорт за замовчуванням санітизований:
Cookie,Set-CookieйAuthorizationу нього не потрапляють, тож живих сесій ви разом із файлом нікому не віддасте.
Навіщо стан взагалі десь тримають
- Можу пояснити, чому в кожен запит доводиться вкладати свіжий доказ, що це той самий користувач: протокол звʼязку між запитами не припускає, а стан застосунку лежить у базі та в тому, що клієнт приніс із собою.
- Знаю різницю двох носіїв цього доказу: кукі браузер підставляє сам за атрибутами, а токен їде лише тоді, коли його поклав у
Authorizationкод застосунку — і саме звідси ростуть два різні сценарії підготовки стану в тестах.
Кукі: обмін і атрибути
- Розумію асиметрію обміну:
Set-Cookieвезе пару разом з атрибутами, а назад уCookieповертаються самі пари — ні строку життя, ні прапорців сервер там не бачить; порядок пар теж не є контрактом. - Знаю, що
Domainобласть розширює, а не звужує: без нього кукі host-only, з ним іде й на піддомени, зокрема . - Можу розвести сесійну й постійну кукі за
Expires/Max-Age(за наявності обох виграєMax-Age) і застерегти, що відновлення вкладок ламає правило «закрив браузер — розлогінився». - Знаю, що
Secureзакриває канал і тільки канал:localhost— виняток, а на HTTP-стенді та сама кукі просто не поїде. - Можу сказати, чого
HttpOnlyНЕ робить: не блокує XSS і не заважає кукі поїхати із запитом, який ініціював JavaScript, — лише віддає скрипту порожній рядок замість значення. - Памʼятаю найпідступніше: нерозпізнаний атрибут браузер ігнорує, а кукі лишає, тож одруківка в назві прапорця мовчки знімає захист; рубіж проти цього — префікси
__Host-(Secure, з HTTPS, безDomain,Path=/) та__Secure-.
SameSite і межа сайту
- Можу пояснити три значення
SameSiteі назвати категоричну вимогу:NoneбезSecureбраузер відкидає кукі цілком. - Знаю, що дефолтний
Laxдозвільніший за явний — свіжа кукі потрапляє й уPOSTпротягом двох хвилин після встановлення, — а невідоме значення теж читається якLax. - Тримаю в голові критерій сайту: схема разом із , і порт до цієї пари не належить; піддомени дають один сайт при різних , а зміна схеми розводить адреси по різних сайтах.
Сесія на сервері проти токена в клієнта
- Можу описати класичний обмін (запис сесії на сервері, довгий випадковий ідентифікатор кукою) і сказати, чому цей ідентифікатор закривають
HttpOnlyтаSecure: після входу він тимчасово рівноцінний найсильнішому методу автентифікації застосунку. - Знаю ціну вибору «стан на сервері проти стану в токені»: серверний запис відкликається миттєво, самодостатній токен — важко, а для тесту це різниця між «перенести кукі» і «відтворити сховище плюс заголовок».
Сховища браузера й межа походження
- Знаю різницю
localStoragevssessionStorageза трьома осями: область (походження проти походження й вкладки), час життя й спільність між вкладками. - Можу назвати головний водорозділ між кукою і Web Storage — автовідправка, а не обсяг: сховище читає лише код сторінки, і на сервер дані потраплять, тільки якщо застосунок їх туди поклав.
- Знаю походження як трійку «схема + хост + порт» і розбіжність, що дає найдивніші баги: у походженні порт враховується, а для кукі його ніби немає — на
localhost:3000іlocalhost:4000сховища роздільні, зате кукі одні на двох.
Стан і ізоляція в автотестах
- Знаю, що збережений стан покриває кукі,
localStorage, IndexedDB і , алеsessionStorage— ні, і це найчастіша причина «стан підхопився, а користувач розлогінений». - Памʼятаю обовʼязки навколо файлу стану: за протуханням ніхто не стежить, файл — живий секрет і в репозиторій не їде, кілька ролей означають кілька файлів; а сам логін швидше й надійніше проходити , бо стан приймають обидва контексти.
- Тримаю напоготові порядок діагностики «розлогіненого» тесту й знаю, у яких двох місцях DevTools його проходити: чи лежить кукі в сторі панелі Application → чи потрапила вона у вкладку Cookies самого запиту (там же фільтр заблокованих кукі з причиною) → звірка
Domain,Path,SecureйSameSiteз адресою стенда, і аж потім бекенд. - Розумію, що клієнтська чистка не прибирає серверного стану: створене під спільним акаунтом наступний тест побачить навіть із бездоганно порожнім сховищем.
Квіз
Перед стартом
- Питань: 17
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Чому кожен запит мусить нести власний доказ, що це той самий користувач?
