Оформление
Частые ошибки и как их найти
Здесь собраны ошибки, на которых команды чаще всего застревают в РК1. Заголовок записи — то, что видит студент: что пишет DevTools, что вернул curl, что делает приложение. Под ним три части:
- Причина — почему так происходит;
- Как проверить — где в DevTools или каким
curlэто подтвердить; - Как исправить — что поменять в коде или конфиге, со ссылкой на раздел, где тема разобрана.
Часть записей — не баги, а особенности инструментов: исправлять там нечего, важно не «чинить» работающую защиту.
Где смотреть в Chrome DevTools:
- Network → запрос → Headers — заголовки запроса и ответа, в том числе
Cookie,Set-Cookie,Authorization,Origin,Access-Control-*. - Network → запрос → Cookies — какие cookie ушли с запросом (Request Cookies) и какие сервер поставил в ответе (Response Cookies). Если браузер отбросил cookie из ответа, рядом с ней значок, а причина видна при наведении. Это самое надёжное место: здесь видно, что на самом деле ушло и пришло.
- Application → Cookies — cookie в хранилище браузера, с колонками
HttpOnly,Secure,SameSite,Path. - Console — точная причина CORS-отказа. JS о ней не узнаёт: ему достаётся только
TypeError.
Где искать cookie и токены
JWT не видно в Application
Причина. Зависит от варианта.
- Вариант B — так и задумано. Access-токен живёт в переменной JS, а не в cookie и не в
localStorage, поэтому в Application его нет. Единственная cookie варианта —__Secure-refresh, и это не JWT, а случайная строка (variant-b.md). - Вариант A — JWT лежит в cookie
access_tokenсPath=/api. Application показывает cookie, подходящие к адресам, которые страница уже загрузила. Пока страница не сделала ни одного запроса на/api/...,access_tokenв списке может не быть — разбор в следующей записи. - Вариант C — JWT нет вообще: в cookie
__Host-sessionлежитsession_id.
Если API на отдельном origin (api.example.ru), cookie принадлежат хосту API, а не хосту страницы: ищите строки с api.example.ru в колонке Domain или сразу смотрите Network.
Как проверить.
- Вариант B: Network → любой запрос к API → Headers → Request Headers. Там должен быть
Authorizationсо схемойBearerи токеном. Если его нет — фронт не подставляет токен, и запросы получают401. - Вариант A: Network → запрос к
/api/...→ Cookies → Request Cookies. Там должна бытьaccess_token.
Как исправить. В варианте B — ничего: токен в памяти и есть правильное место. Не переносите его в localStorage или в cookie без HttpOnly, «чтобы было видно», — это нарушение минимума (basics.md). В варианте A проверьте запрос в Network; если cookie там нет — смотрите запись «Cookie не сохраняется: после входа 401 или выход после F5».
Cookie пропала из Application после перезагрузки
Например, после F5 в Application больше нет __Secure-refresh. Кажется, что Path её «спрятал» или что она удалилась.
Причина. Application → Cookies показывает cookie, подходящие к адресам, которые страница уже загрузила. После перезагрузки список собирается заново. Cookie с Path=/api/v1/auth подходит только к адресам /api/v1/auth/..., и в списке её нет, пока страница не сделала запрос туда. В варианте A после F5 это обычно так: фронт запрашивает /api/v1/users/me, а refresh вызывает, только если access истёк. Cookie при этом жива и лежит в хранилище браузера.
Это особенность панели, а не защита. Path не граница безопасности: он только сужает, к каким ручкам уходит cookie (basics.md).
Как проверить.
- Если в Network после F5 есть
POST /api/v1/auth/refreshс ответом200, — refresh-cookie жива: без неё refresh не прошёл бы. В варианте B фронт вызывает refresh при каждом старте, в варианте A — когда access истёк. - Network → запрос на
/api/v1/auth/refreshили/api/v1/auth/logout→ Cookies → Request Cookies.__Secure-refreshвидна у каждого запроса, к которому браузер её приложил. - После такого запроса cookie появится и в Application.
- Все cookie домена с любым
Path, включаяHttpOnly, показывает расширение «Все cookie». В Firefox Storage Inspector показывает все cookie хоста с любымPath.
Как исправить. Ничего. Не расширяйте Path до /, «чтобы cookie было видно»: тогда долгий токен будет уходить с каждым запросом к сайту — к API, HTML и статике — без всякой пользы (variant-a.md).
document.cookie пустой, хотя я залогинен
Причина. HttpOnly-cookie не видны JS: их нет в document.cookie. Ради этого флаг и ставят — внедрённый скрипт не сможет прочитать и унести токен (basics.md). Вдобавок document.cookie показывает только cookie, которые подходят к хосту и пути текущей страницы.
Что должно быть видно в document.cookie на странице https://example.ru:
| Вариант | Что видно | Почему |
|---|---|---|
| A | только __Host-csrf=... | access_token и __Secure-refresh — HttpOnly; CSRF-токен фронт обязан прочитать |
| B | ничего | единственная cookie, __Secure-refresh, — HttpOnly |
| C | только __Host-csrf=... | __Host-session — HttpOnly |
Если API на отдельном origin, в вариантах A и C не видно и __Host-csrf: она принадлежит хосту api.example.ru. Поэтому CSRF-токен фронт берёт из заголовка ответа X-CSRF-Token (variant-a.md).
Как проверить.
- Application → Cookies: у auth-cookie стоит галочка в колонке
HttpOnly, у__Host-csrf— нет. - Network → запрос к API → Cookies → Request Cookies: здесь видны все cookie, которые ушли с запросом, включая
HttpOnly. - Если в
document.cookieвиден токен илиsession_id— например,access_token=..., — у этой cookie нетHttpOnly. Это нарушение минимума. - Все cookie домена с любым
Path, включаяHttpOnly, показывает расширение «Все cookie». В Firefox Storage Inspector показывает все cookie хоста с любымPath.
Как исправить. Если пусто совсем, а в варианте A или C должна быть __Host-csrf: проверьте, что на бэке стоит ensureCSRFCookie и ставит cookie на любой ответ API, в том числе на 401 стартового GET /api/v1/users/me (variant-a.md), и что браузер не отбросил cookie из ответа (следующая запись). Снимать HttpOnly с авторизационной cookie, чтобы фронт её «увидел», нельзя: фронту не нужно читать токены, браузер прикладывает их к запросам сам.
Cookie не ставится или не уходит
Cookie не сохраняется: после входа 401 или выход после F5
Причина. Браузер не сохранил cookie из ответа на вход или не прикладывает её к следующим запросам. Как это выглядит, зависит от варианта:
- A и C — эти причины обычно видны раньше: браузер так же теряет
__Host-csrfиз ответа на стартовыйGET /api/v1/users/me, и вход отвечает403(следующая запись). Забытыйcredentials: 'include'— даже только на login — здесь тоже даёт403на входе: без него к запросу не приложится__Host-csrf. Если же CSRF-cookie дошла, а cookie сессии браузер отбросил, вход отвечает200, а следующий запрос —401. - B — вход проходит, и запросы с
Authorizationработают: refresh-cookie им не нужна. Потеря видна после F5: refresh при старте получает401, и приложение открывает страницу входа.
Частые причины:
- API на отдельном origin, а в
fetchнетcredentials: 'include'. По умолчанию уfetchстоитcredentials: 'same-origin': на другой origin cookie не уходят, аSet-Cookieиз ответа браузер игнорирует (cors.md). - Стенд открыт по
http://, и это неlocalhost. Страница поhttp:не может поставить cookie сSecure, а cookie с префиксом__Secure-или__Host-безSecureи HTTPS браузер отбрасывает (basics.md). Сервер присылаетSet-Cookie, а в браузере ничего не появляется. SameSite=NoneбезSecure. Такую cookie браузер тоже отбрасывает.
Как проверить.
- Network → ответ на
POST /api/v1/auth/login(в A и C — сначала на стартовыйGET /api/v1/users/me) → Cookies → Response Cookies. Если браузер отбросил cookie, рядом с ней значок, а при наведении — причина, например проSecureна незащищённом соединении. В Network → More filters есть фильтр Blocked response cookies. - Network → следующий запрос к API → Headers: есть ли в Request Headers заголовок
Cookie. ЕслиSet-Cookieв ответе на вход был, аCookieв следующем запросе нет, — смотрите наcredentials.
Как исправить.
- Ставьте
credentials: 'include'во всех запросах к API в одной обёртке надfetch(variant-a.md). - Стенду нужен HTTPS: домен и сертификат (например, от Let's Encrypt). Убирать
Secureи префиксы ради стенда — не выход: на продеSecureобязателен по минимуму. - Для локальной разработки работайте через
localhost: по MDN требованиеhttps:дляSecureна нём не действует. А чтобы не возиться с CORS, проксируйте API через dev-сервер (cors.md). Если какой-то браузер всё же не сохраняет cookie наhttp://localhost, включите HTTPS у dev-сервера.
Логин или logout отвечает 403 или 415
Чаще всего — в вариантах A и C, где CSRF-токен проверяется на каждом изменяющем запросе, включая POST /api/v1/auth/login и /register. Отказ по Origin и 415 бывают и в варианте B.
Причина. Сервер отклонил запрос до обработчика:
403, нет CSRF-токена. На бэке нетensureCSRFCookie, и до входа cookie никто не поставил; фронт не поставил заголовокX-CSRF-Tokenили поставил его с пустым значением. Пустое значение бывает, когда API на отдельном origin, а токен читают изdocument.cookie(запись выше). Ещё случай: cookie удалили (в другой вкладке, вручную в DevTools), и первым запросом сразу идёт вход.403, CSRF-cookie не сохранилась или не ушла. Стенд поhttp://(браузер отбрасывает__Host-csrf) или нетcredentials: 'include'при отдельном API — разбор в записи выше.403, токен устарел. После входа сервер выдаёт новый CSRF-токен. Если фронт сохранил старый в переменной, logout и все запросы после входа отклоняются.403от проверкиOrigin. При разработке через прокси ViteOriginравенhttp://localhost:5173, и его нет в белом списке dev-окружения (csrf.md).415, нетContent-Type: application/json. Если сервер принимает на изменяющих ручках только JSON (настойчиво рекомендуется), logout или refresh без тела и без этого заголовка он отклонит.
Как проверить.
- Network → упавший запрос → Headers: есть ли
X-CSRF-TokenиContent-Type: application/jsonв Request Headers. В Cookies → Request Cookies: ушла ли__Host-csrfи совпадает ли её значение с заголовком. - Тело ответа: код ошибки (
csrf_invalidили ваш аналог) отличает отказ по токену от отказа поOrigin. 403вcurlна/api/v1/auth/loginбезX-CSRF-Token— правильное поведение, а не баг (csrf.md).
Как исправить.
- Поставьте
ensureCSRFCookieперед CSRF-проверкой на всех ручках API (variant-a.md): тогда cookie приходит с любым ответом, даже с403. Если изменяющий запрос без cookie получил403, этот же ответ уже принёс новую — повторите запрос один раз, без цикла: вход, refresh, выход и все остальные (csrf.md). Особенно refresh, если с него начинается проверка сессии: иначе пользователь, вошедший до появления CSRF-защиты, при первом заходе попадёт на страницу входа. - Читайте токен из
document.cookieперед каждым запросом, а не один раз при старте — какcsrfToken()в variant-a.md. При отдельном API берите его из заголовка ответаX-CSRF-Tokenи обновляйте после каждого ответа: после входа сервер выдаёт новый токен, и старое значение в памяти больше не подойдёт. Content-Type: application/json— на всех изменяющих запросах, с телом и без.http://localhost:5173— в белый списокOrigindev-окружения (не прода, см. ниже).
CORS
В ответе curl нет CORS-заголовков
Причина. Запрос ушёл без заголовка Origin. Его ставит браузер, а curl — нет. CORS нужен только браузеру, поэтому middleware (например, rs/cors) добавляет Access-Control-Allow-*, только если в запросе есть Origin (cors.md).
Как проверить. Передайте Origin явно. Разрешённый origin получит заголовки:
bash
curl -i https://api.example.ru/api/v1/files -H 'Origin: https://example.ru'Preflight — методом OPTIONS и с Access-Control-Request-Method:
bash
curl -i -X OPTIONS https://api.example.ru/api/v1/files/42 \
-H 'Origin: https://example.ru' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: content-type,x-csrf-token'С Origin: https://evil.example в ответе не должно быть Access-Control-Allow-Origin. Готовые команды для приёмки — в checklist.md.
Как исправить. Ничего: это не баг. Если фронт и API на одном origin и CORS-middleware не подключён (он там не нужен), CORS-заголовков не будет и с Origin (cors.md).
Preflight получает 401
В Console — примерно «Response to preflight request doesn't pass access control check: It does not have HTTP ok status». В Network перед основным запросом строка OPTIONS с кодом 401, а сам запрос не уходит, хотя пользователь вошёл.
Причина. OPTIONS попал в auth-middleware. Preflight браузер отправляет без cookie и без заголовков, которые ставит код, — значит, и без Authorization. Auth-middleware видит запрос без сессии и отвечает 401. Успешный preflight для браузера — только с кодом 200–299, поэтому основной запрос не отправляется (cors.md).
Как проверить. Команда preflight из предыдущей записи должна вернуть 204 (или 200) с Access-Control-Allow-Origin: https://example.ru. Если пришёл 401 — CORS-middleware стоит после auth.
Как исправить. CORS-middleware — снаружи, он отвечает на preflight сам и не зовёт следующие обработчики: c.Handler(authMiddleware(router)). В gorilla/mux не вешайте CORS через router.Use(...): middleware роутера вызываются, только когда нашёлся маршрут, а маршрута с OPTIONS у вас, скорее всего, нет (cors.md).
Access-Control-Allow-Origin: *, а запрос с cookie падает
В Console — примерно «The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'». В Network ответ может прийти с 200, а JS всё равно получает TypeError.
Причина. Для запроса с credentials: 'include' браузер отдаёт ответ JS, только если в Access-Control-Allow-Origin конкретный origin и есть Access-Control-Allow-Credentials: true. Со звёздочкой ответ не отдаётся (cors.md). В rs/cors так выходит с AllowedOrigins: []string{"*"} и AllowCredentials: true: библиотека отвечает * вместе с Allow-Credentials: true.
Как проверить. curl с Origin из записи про curl: в ответе должен быть ровно ваш origin, а не *, плюс Access-Control-Allow-Credentials: true и Vary: Origin.
Как исправить.
- Точный белый список origin из переменной окружения и сравнение
Originна равенство (cors.md). - Не «чините» ошибку отражением любого
Originобратно в ответ. Например,AllowOriginFunc, который всегда возвращаетtrue:rs/corsтогда подставляет вAllow-Originorigin из запроса. Браузер перестанет ругаться, но любой сайт сможет читать ответы API с cookie пользователя, еслиSameSiteих пропустит. - Если фронт и API можно отдать с одного origin через reverse proxy — сделайте так, и CORS не понадобится (cors.md).
localhost в белом списке прода
Симптома нет — всё работает. Ошибку находят на ревью: прод-API разрешает CORS для http://localhost:5173.
Причина. localhost добавили, чтобы локальный фронт ходил в прод-API или стенд, и не убрали. Белый список — это перечень origin, которым API доверяет. http://localhost:5173 у пользователя — не ваш фронт, а любая программа на его компьютере, которая слушает этот порт: dev-сервер другого проекта, локальная утилита. К тому же это http.
Насколько это опасно, зависит от остальных настроек. С SameSite=Lax или Strict браузер не приложит cookie к fetch со страницы localhost: для example.ru это другой site. Но localhost в проде часто идёт в паре с SameSite=None — тоже «чтобы локальный фронт работал». Тогда любая страница на localhost читает API от имени пользователя. А проверка Origin на входе (csrf.md) с тем же белым списком пропустит такую страницу.
Как проверить.
bash
curl -i https://api.example.ru/api/v1/users/me -H 'Origin: http://localhost:5173'В ответе прода не должно быть Access-Control-Allow-Origin. Заодно проверьте, что у авторизационных cookie SameSite=Lax или Strict, а не None.
Как исправить. Белый список — из переменной окружения, свой для каждого окружения: http://localhost:5173 только в dev (cors.md). Локально проще проксировать API через dev-сервер, тогда CORS не нужен вовсе (cors.md).
Refresh и перезагрузка
Бесконечный refresh
В Network по кругу: изменяющий запрос → 401 → POST /api/v1/auth/refresh → 200 → повтор → 401 → снова refresh. Или пользователя без причины выкидывает на страницу входа.
Причина. Отказ CSRF-проверки отвечает 401 вместо 403. На 401 фронт делает refresh и повторяет запрос, но refresh не чинит CSRF-токен: повтор снова получает 401. Обёртка без ограничения повторов зацикливается. Если 401 от CSRF получит и сам refresh, фронт решит, что сессия кончилась (csrf.md).
Тот же круг бывает, если после refresh новая cookie не сохранилась или не уходит с повтором: повтор опять приходит без access.
Как проверить.
- Тело ответа
401: если там отказ CSRF-проверки, причина найдена. curlбезX-CSRF-Tokenна изменяющую ручку должен вернуть403, а не401(csrf.md).- Network → повторный запрос → Cookies: ушёл ли с ним новый
access_token. Если нет — смотрите запись «Cookie не сохраняется: после входа401или выход после F5».
Как исправить.
- Бэк: отказ CSRF —
403;401— только «нет сессии или токен истёк» (variant-a.md). - Фронт: на
403не делать refresh, а показать ошибку; после refresh повторять запрос один раз; ручки/api/v1/auth/...после401не продлевать; один refresh на все одновременные401(variant-a.md).
Токен в localStorage, чтобы пережить F5
Симптом, с которого всё начинается: после F5 приложение выкидывает на страницу входа. Команда кладёт токен в localStorage — и проблема «решена».
Причина. Память JS после перезагрузки пуста. В варианте B access-токен теряется, и фронт не восстанавливает сессию при старте. В вариантах A и C фронт хранит состояние «вошёл» только в памяти и не спрашивает сервер. А localStorage любой скрипт на нашем origin читает одной строкой: при XSS токен уходит злоумышленнику. Это нарушение минимума (basics.md).
Как проверить.
- Application → Local Storage и Session Storage: ни access, ни refresh, ни
session_id. - Стор с persist (Zustand
persist,redux-persist) обычно пишет вlocalStorage— токена в сторе с persist быть не должно (variant-b.md).
Как исправить. Для переживания F5 есть refresh-cookie, она остаётся в браузере.
- Вариант B: при старте приложения —
POST /api/v1/auth/refresh, затемGET /api/v1/users/me(variant-b.md). - Варианты A и C: при старте —
GET /api/v1/users/me; в A при401— refresh и повтор (variant-a.md). - Если после этого F5 всё равно выкидывает — проверьте, что браузер сохранил cookie (запись выше).
Источники
- MDN: Set-Cookie —
HttpOnly,Secureи исключение дляlocalhost,SameSite=Noneтолько сSecure,Path, префиксы__Host-и__Secure-; Document: cookie —HttpOnly-cookie недоступны JS - MDN: Window: localStorage
- MDN: Cross-Origin Resource Sharing (CORS) — preflight, credentials,
Vary: Origin; CORSNotSupportingCredentials —*вместе с credentials; CORSPreflightDidNotSucceed — preflight с кодом не из диапазона 2xx; RequestInit: credentials - MDN: 401 Unauthorized, 403 Forbidden, 415 Unsupported Media Type
- RFC 6265bis (draft-ietf-httpbis-rfc6265bis) — сопоставление по
Path,HttpOnlyи доступ из скриптов,Secure,SameSite, проверка префиксов при установке - Fetch Standard: CORS protocol —
Origin, CORS check с credentials, CORS-preflight fetch (успех — статус 200–299) - OWASP: Cross-Site Request Forgery Prevention Cheat Sheet — Double Submit Cookie, проверка
Origin, login CSRF - OWASP: HTML5 Security Cheat Sheet, Cross Origin Resource Sharing — белый список вместо
*и отраженияOrigin; Local Storage — не хранить в нём токены сессии - Chrome DevTools: Network features reference — вкладка Cookies у запроса, заблокированные cookie из ответа и причина при наведении; View, add, edit, and delete cookies — Application → Cookies, колонка
HttpOnly - rs/cors — CORS-заголовки только при
Originв запросе,*приAllowedOrigins: ["*"], отражение origin приAllowOriginFunc; gorilla/mux, Middleware