АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

Почему FastAPI за Nginx возвращает 200 на неверный пароль, а браузер не сохраняет session cookie

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~10 мин чтения
Почему FastAPI за Nginx возвращает 200 на неверный пароль, а браузер не сохраняет session cookie

Неверный пароль получает 200, правильный тоже, но кабинет всё равно отправляет пользователя на страницу входа. Я — Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш». Покажу разработчику, как отделить ошибку обработчика от браузерных ограничений cookie, проверить участие Nginx и перестать менять три конфигурации одновременно.

Сначала разделяю два симптома

Неверный пароль, зелёный 200 в Network, затем возврат на форму входа. Первая реакция — поправить CORS и перезапустить Nginx. Я начинаю с другого: устанавливаю, какой именно запрос получил 200 и какой компонент его сформировал. Успешный OPTIONS означает разрешённый preflight. Успешный POST с JSON об ошибке означает, что приложение неправильно выразило результат проверки. Это разные события, хотя в панели браузера они стоят рядом.

Для разбора возьму учебную реконструкцию портала условного ООО «Вектор» на 120 рабочих мест. Это вымышленная организация, а не раскрытый клиентский кейс. В схеме два адреса: https://app.example.test и https://api.example.test; TLS заканчивается на Nginx, дальше запрос идёт к Uvicorn по loopback. Домен .test используется для локального стенда: понадобятся локальное разрешение имён и доверенные тестовые сертификаты. Численность компании здесь задаёт контекст, а не результаты нагрузочного тестирования.

Серверную причину я воспроизвёл на Python 3.12.3, FastAPI 0.135.1, Starlette 0.52.1, HTTPX 0.28.1 и itsdangerous 2.2.0. Это закреплённые версии эксперимента, не список последних релизов. Восемь сценариев через TestClient подтвердили: ранний return даёт 200, исправленный отказ — 401, успешный вход создаёт cookie, следующий /me возвращает uid=42. Отдельный HTTP-эксперимент с Nginx 1.30.4 и тестовым backend подтвердил передачу статуса и Set-Cookie; конфигурация прошла nginx -t. Браузерные ограничения эти проверки не воспроизводят.

Я завожу два критерия готовности. Первый: неверные учётные данные не создают новую авторизованную сессию и получают ожидаемый статус. Второй: после правильного пароля браузер принимает cookie и отправляет её на /me. Пока оба критерия не выполнены, фраза «авторизацию починили» преждевременна. При этом один неверный 200 ещё не доказывает обход проверки пароля: интерфейс мог показать успех, а защищённый API продолжать возвращать отказ.

Статус ответа и доставка cookie — две независимые проверки. Исправление одной не подтверждает исправность другой.
Цифры и версии: Сначала разделяю два симптома — схема
Цифры и версии: Сначала разделяю два симптома

Ранний return превращает отказ в обычный ответ

Вот сокращённая неисправная ветка. Проверка пароля уже сообщила об отказе, но обработчик вернул словарь раньше исключения. При стандартном статусе маршрута FastAPI сериализует этот словарь в JSON и отвечает 200. Содержимое поля error не управляет HTTP-статусом; строка с raise ниже return вообще не выполняется. Такой дефект удобно искать от первой точки выхода из функции, а не от последней строки с правильным исключением. [Статусы ответов FastAPI](https://fastapi.tiangolo.com/tutorial/response-status-code/).

Ниже фрагменты одного обработчика до и после исправления. LoginIn — модель входных данных проекта, authenticate — его функция проверки пользователя и хеша пароля; она возвращает пользователя либо None. ```python # Ошибочная ветка if user is None: return {"error": "invalid credentials"} raise HTTPException(status_code=401) # Исправленный обработчик @api.post("/auth/login") async def login(data: LoginIn, request: Request): user = await authenticate(data.username, data.password) if user is None: raise HTTPException( status_code=401, detail="Invalid credentials" ) request.session.clear() request.session["uid"] = user.id return {"ok": True} ``` Я выбираю raise HTTPException: неуспешная ветка заканчивается сразу, создание сессии остаётся после проверки. JSONResponse с явно заданным status_code тоже работает, но для этой ветки дополнительный объект мне не нужен. Исключение нужно именно выбросить, а не вернуть. Проверьте также общий except Exception: он способен поймать HTTPException и снова превратить отказ в словарь с 200. [Обработка ошибок FastAPI](https://fastapi.tiangolo.com/tutorial/handling-errors/).

Есть более опасная ошибка: записать uid в request.session до проверки пароля и рассчитывать, что статус 401 отменит запись. В Starlette 0.52.1 SessionMiddleware при формировании ответа проверяет содержимое session, а не успешность статуса. В эксперименте отказ на чистом клиенте не создавал cookie, однако отказ у уже вошедшего клиента переиздавал прежнюю. Поэтому я проверяю личность в /me и момент изменения session, а не одно наличие Set-Cookie. [Исходник версии 0.52.1](https://raw.githubusercontent.com/Kludex/starlette/0.52.1/starlette/middleware/sessions.py).

На фронтенде я отдельно проверяю response.ok. Сам fetch не отклоняет Promise из-за HTTP 401, поэтому один catch не заменяет обработку статуса. Если backend ошибочно вернул 200, response.ok будет true: интерфейс откроет кабинет, затем /me откажет, и пользователь снова увидит форму. Получается убедительная иллюзия пропавшей сессии, хотя новую сессию вообще не создавали. [Обработка ответа Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#handling_the_response).

Сначала проверяйте пароль, затем записывайте авторизованного пользователя в session. HTTP 401 сам по себе не откатывает изменения сессии.
Почему FastAPI за Nginx возвращает 200 на неверный пароль, а браузер не сохраняет session cookie — схема

Nginx проверяю по двум статусам и одному маршруту

Первую пару запросов отправляю с хоста приложения: напрямую в Uvicorn и через публичный HTTPS-адрес. Метод, путь и тестовый JSON должны совпадать. На первом проходе не добавляю curl -L: автоматический переход способен спрятать исходный редирект за конечным 200. В примере используется заведомо неверный тестовый пароль. ```bash curl -i http://127.0.0.1:8000/auth/login -H 'Host: api.example.test' -H 'Content-Type: application/json' --data '{"username":"demo","password":"wrong"}' curl -i https://api.example.test/auth/login -H 'Content-Type: application/json' --data '{"username":"demo","password":"wrong"}' ``` Если оба POST вернули 200 с одинаковым JSON об ошибке, начинаю с обработчика. Если напрямую 401, а снаружи 200, проверяю выбранный location, error_page, SPA fallback и промежуточные сервисы.

Для Nginx полезен журнал, где рядом стоят $status и $upstream_status. Первый показывает итоговый ответ клиенту, второй — ответ upstream. При единственном обращении к backend пара 401/401 ожидаема; 200/401 заставляет искать преобразование после upstream. Отсутствующее значение upstream_status — повод выяснить, дошёл ли запрос до приложения. При повторных обращениях значений бывает несколько, поэтому читать последнюю цифру без контекста нельзя. [Переменная upstream_status](https://nginx.org/en/docs/http/ngx_http_upstream_module.html#var_upstream_status).

Внутри существующего HTTPS server я использую такой минимальный фрагмент; log_format размещается в http. Сертификаты настраиваются отдельно. proxy_pass здесь сохраняет исходный путь: маршрут FastAPI тоже называется /auth/login. ```nginx # В контексте http log_format auth_debug '$request_id $request_method $uri ' 'status=$status upstream=$upstream_status'; # Внутри HTTPS server для api.example.test access_log /var/log/nginx/auth-debug.log auth_debug; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_intercept_errors off; proxy_cache off; } ``` Для одного пограничного прокси я перезаписываю X-Forwarded-For адресом клиента. У цепочки балансировщиков будет другая модель доверия. Nginx обычно передаёт Set-Cookie без специальных разрешающих директив; если заголовок исчез, ищите proxy_hide_header и наследуемые настройки. [Документация proxy-модуля](https://nginx.org/en/docs/http/ngx_http_proxy_module.html).

Для Uvicorn 0.41.0 команда запуска в этой схеме выглядит так: `uvicorn main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips=127.0.0.1`. Доверенные forwarded-заголовки нужны, в частности, для корректной внешней схемы в редиректах. Они не меняют return на raise и не добавляют credentials браузеру. Secure-cookie работает при внешнем HTTPS, даже если локальный участок до приложения — HTTP. В контейнерах loopback и адрес доверенного прокси надо пересмотреть. Перед загрузкой конфигурации выполняю nginx -t. [Настройки Uvicorn](https://uvicorn.dev/settings/), [FastAPI за прокси](https://fastapi.tiangolo.com/advanced/behind-a-proxy/).

Не записывайте пароли, Cookie и значения Set-Cookie в диагностический access log. Для этой проверки достаточно маршрута, идентификатора запроса и статусов.
Обратите внимание: Nginx проверяю по двум статусам и одному маршруту — схема
Обратите внимание: Nginx проверяю по двум статусам и одному маршруту

Cookie проходит проверки credentials, SameSite и Secure

Дальше беру только успешный вход. В нашем примере app.example.test и api.example.test имеют разные origin, поскольку hostname отличается. Однако при HTTPS с обеих сторон это один site: схема и регистрируемый домен совпадают. SameSite=Lax подходит для такого обращения из приложения, открытого как обычная верхнеуровневая страница. Соседний поддомен сам по себе не требует SameSite=None. Внешний iframe меняет контекст — его надо разбирать отдельно. [Различие site и origin](https://web.dev/articles/same-site-same-origin).

При этом fetch по умолчанию использует credentials: "same-origin". Для запроса между нашими поддоменами я задаю include и на login, и на последующих запросах. Этот параметр влияет на отправку cookie и принятие Set-Cookie. Добавлять его только к /me поздно: login мог уже пройти без сохранения cookie. ```javascript const response = await fetch( "https://api.example.test/auth/login", { method: "POST", credentials: "include", headers: {"Content-Type": "application/json"}, body: JSON.stringify({username, password}) } ); if (!response.ok) throw new Error(`Login: ${response.status}`); const me = await fetch("https://api.example.test/me", { credentials: "include" }); ``` Здесь username и password — значения формы. [Семантика credentials](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials).

Если frontend находится на другом site, обычный cross-site fetch с cookie требует SameSite=None; Secure. Это необходимые условия, но не гарантия: ограничения сторонних cookie зависят от браузера, профиля и настроек. В 2026 году утверждение «добавьте None, и везде заработает» по-прежнему неверно. Мой выбор для корпоративного кабинета — общий origin с API под /api либо хотя бы HTTPS-поддомены одного site. Архитектуру с обязательной сторонней cookie беру только при реальной необходимости и проверяю в целевых браузерах. [Правила сторонних cookie](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Third-party_cookies).

Наконец, проверяю Domain и Path. Cookie от API не обязана принадлежать frontend-хосту: без Domain браузер привяжет её к api.example.test и отправит туда же. Расширять Domain на всех соседей ради «видимости» я не выбираю. Path=/ покрывает и login, и /me. Secure требует защищённого браузерного соединения; исключения для localhost не стоит переносить на произвольный HTTP-стенд. Эти ограничения независимы: include не отменяет ни SameSite, ни Secure, ни область действия cookie. [Атрибуты Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie).

Cross-origin не означает cross-site. Для двух HTTPS-поддоменов обычно достаточно SameSite=Lax, но credentials: "include" всё равно нужен.

Настраиваю session и CORS в одном месте

В приложении оставляю одного владельца CORS-заголовков. Для нашей пары поддоменов конфигурация ниже дополняет обработчики, зарегистрированные на api. Секрет поступает из окружения, остаётся одинаковым у всех процессов и переживает перезапуск. Новый случайный секрет при каждом старте объясняет «периодические разлогинивания» без участия Nginx. Пакет itsdangerous требуется SessionMiddleware. ```python import os from fastapi import FastAPI, HTTPException, Request from starlette.middleware.cors import CORSMiddleware from starlette.middleware.sessions import SessionMiddleware api = FastAPI() api.add_middleware( SessionMiddleware, secret_key=os.environ["SESSION_SECRET"], session_cookie="session", max_age=1800, same_site="lax", https_only=True, path="/", ) # Обработчики регистрируются через @api.get / @api.post app = CORSMiddleware( api, allow_origins=["https://app.example.test"], allow_credentials=True, allow_methods=["GET", "POST"], allow_headers=["Content-Type"], ) ``` HttpOnly middleware добавляет самостоятельно. Max-Age=1800 задаёт 30 минут; session здесь — имя cookie приложения, а не обещание удалить её при закрытии браузера. [Настройки middleware Starlette](https://starlette.dev/middleware/).

Origin в allow_origins указан без пути и завершающего слеша. С credentials ответу нужен конкретный Access-Control-Allow-Origin и Access-Control-Allow-Credentials: true; звёздочка в Allow-Origin не подходит. Методы и заголовки я тоже перечисляю явно. Внешняя обёртка CORSMiddleware помогает сохранить CORS-заголовки на ошибках приложения. Ответы, которые сформировал сам Nginx, через неё не проходят — например, его 502 требует отдельной диагностики. [CORS в FastAPI](https://fastapi.tiangolo.com/tutorial/cors/).

Для JSON POST браузер обычно выполняет preflight OPTIONS. Он не обязан содержать session cookie, и требовать от него успешной авторизации нельзя. Если preflight не прошёл, основной POST браузер не отправит. Но когда POST уже выполнен, ошибка CORS при чтении ответа не доказывает откат действия или отсутствие сохранённой cookie. CORS регулирует доступ JavaScript к ответу; решение о cookie нужно проверять отдельно. Поэтому я не маскирую проблему режимом no-cors: читаемого ответа для логики входа он не даст. [Алгоритмы Fetch](https://fetch.spec.whatwg.org/#http-fetch).

SessionMiddleware хранит подписанные данные в cookie: подпись не делает их зашифрованными. Для примера достаточно uid; пароль и секреты туда не помещаю. Если нужен немедленный серверный отзыв отдельных сессий, выбираю серверное хранилище с непрозрачным идентификатором. И CORS не заменяет CSRF-защиту: для изменяющих запросов предусматриваю проверку Origin и подходящую схему CSRF-токенов. Особенно внимательно — при SameSite=None. Полную реализацию аутентификации нельзя свести к этому конфигурационному фрагменту. [Рекомендации OWASP по CSRF](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html).

Не добавляйте CORS одновременно в FastAPI и Nginx без конкретной необходимости: дублирующиеся заголовки создают новую неисправность поверх исходной.

Закрываю задачу по цепочке от Set-Cookie до /me

В браузере я сначала открываю Network и нахожу именно POST /auth/login. Смотрю статус, цепочку редиректов, Set-Cookie и причину блокировки, если она показана. Затем проверяю хранилище cookie у API-хоста, после этого — заголовок Cookie у запроса /me. Это три разные точки наблюдения. Если смотреть только последнюю, невозможно понять, cookie не создали, не приняли или не отправили. Chrome DevTools показывает атрибуты и диагностические сообщения для cookie. [Работа с cookie в DevTools](https://developer.chrome.com/docs/devtools/application/cookies).

Не ищите HttpOnly-cookie через document.cookie: JavaScript её не видит по назначению. Чтение response.headers.get("set-cookie") тоже не является проверкой: браузер не раскрывает этот заголовок frontend-коду, даже через expose_headers. Если Cookie уже присутствует в запросе /me, а пользователь не определился, исследование переходит на сервер: имя cookie, подпись, срок действия, ключи между процессами и содержимое сессии. Переставлять SameSite на этом этапе обычно бессмысленно. [Ограничения доступа к Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie).

Разбор закончился проверяемыми результатами. В таблице — серверный TestClient; отдельная проверка Nginx показала 401/401 для отказа и 200/200 для ответа с неизменённым Set-Cookie. Браузерная приёмка остаётся самостоятельной проверкой. | Сценарий | Статус | Результат для сессии | | --- | --- | --- | | Неверный пароль, ранний return | 200 | Новая сессия не создана | | Неверный пароль после исправления, чистый клиент | 401 | Set-Cookie отсутствует | | Правильный пароль | 200 | Cookie: HttpOnly, Secure, SameSite=lax, Path=/, Max-Age=1800 | | HTTPS-запрос /me после входа | 200 | Получен uid=42 | | Неверный пароль при существующей сессии | 401 | Прежняя cookie переиздана | Разрешённый preflight дополнительно дал 200, а preflight с чужим Origin — 400.

Для браузерной приёмки я повторяю проверки ниже в чистом профиле и фиксирую версию браузера. curl с cookie jar полезен для проверки серверного обмена, но не воспроизводит SameSite и fetch credentials. Мой приоритет простой: сначала контракт отказа, затем успешная выдача сессии, потом браузерная доставка. Увеличение таймаутов, числа workers и размеров буферов оставляю до появления соответствующих симптомов. Ни дополнительное ядро, ни ещё один рестарт прокси не выполнят строку после return.

Наличие Set-Cookie на 401 не доказывает успешную авторизацию. Проверяйте, какому пользователю принадлежит сессия и какой доступ она даёт.
Памятка: Закрываю задачу по цепочке от Set-Cookie до /me — схема
Памятка: Закрываю задачу по цепочке от Set-Cookie до /me

Частые вопросы

Почему OPTIONS возвращает 200 даже с неверным паролем?

Preflight проверяет разрешение на междоменный запрос, а не пароль. Найдите отдельный POST /auth/login и проверьте его статус.

Нужно ли ставить SameSite=None между поддоменами?

Обычно нет: HTTPS-поддомены одного регистрируемого домена относятся к одному site. Для обычной верхнеуровневой страницы подходит Lax, но cross-origin fetch требует credentials: "include".

Почему curl работает, а браузер не сохраняет сессию?

curl не воспроизводит браузерные правила CORS, SameSite и fetch credentials. Проверяйте Set-Cookie, хранилище API-хоста и Cookie следующего запроса в DevTools.

Разберём сбой авторизации вместе
Я помогу найти причину сбоя авторизации и проверить исправление от обработчика FastAPI до браузера. Пришлите схему размещения, конфигурацию прокси и обезличенные заголовки запросов — без паролей и значений session cookie.
Бесплатная консультация →
© ООО «АйТи-Фреш» · Москва · Все статьи