Признайся, разработчик, ты же любишь отправлять пароль в теле POST‑запроса из формы входа открытым текстом, ведь «там же TLS, всё зашифровано»? Судя по тому что я вижу в интернете, эту традицию соблюдают многие, и ИИ — не исключение. Одни продолжают запрашивать по сети пароли, прикрытые звёздочками, другие — используют одинаковый пароль в десятке различных сервисов. Даже Госуслуги не стали исключением. Открытый пароль гуляет неизвестно где, но все спокойны. «Задача неприоритетна» уже много лет.

Что не так?
В подавляющем большинстве продакшн‑инфраструктур TLS терминируется на балансировщике, reverse‑proxy (nginx, Envoy, HAProxy) или на CDN/edge‑узле (Cloudflare, Fastly, Yandex Cloud). Дальше, внутри периметра — до контейнера с приложением, до соседнего микросервиса, до очереди сообщений — трафик идёт по обычным HTTP, gRPC и так далее, потому что «мы же внутри, тут все свои». Пароль в этот момент — обычная строка в теле запроса, читаемая любым, кто оказался на этом внутреннем участке пути. Утечка может произойти через банальный log.info(req.body), забытый в middleware, или через APM/error‑tracking системы типа Sentry, которые могут сохранить полное тело HTTP‑запроса при ошибке, а аутентификация рано или поздно отпятисотит.
Это происходило с крупными компаниями:
В 2018 году GitHub обнаружил у себя баг, из‑за которого пароли пользователей в открытом виде записывались во внутренние логи компании, и эти незашифрованные пароли были доступны небольшому числу сотрудников GitHub. При этом в обычном режиме пароли надёжно хранились через bcrypt — проблему создал именно баг, из‑за которого plaintext‑версия попадала в лог.
Практически одновременно похожая история случилась у Twitter: компания сообщила о баге, из‑за которого пароли пользователей сохранялись во внутреннем логе в незамаскированном виде. По словам техдиректора компании, из‑за ошибки пароли записывались в лог до того, как полностью проходили процесс хеширования, хотя в норме система использовала bcrypt для маскирования паролей.
В обоих случаях TLS работал штатно. Проблема была в том, что где‑то на пути между приёмом запроса и вызовом функции хеширования пароль на короткое время существовал в виде обычной строки, и эта строка попала не туда.
Пароль — это тайна
Ты наверное с оголтелым снобизмом заявишь что пароли от разных сервисов должны быть разные, сложные, храниться в менеджере паролей, и так далее. Но в реальном мире это не работает. Клиент всегда будет пытаться задать пароль «111». (часто просто потому, что ему вообще не нужна была регистрация). И только ограничения разработчика заставляют его уйти придумать правильный пароль. Можно ограничить количество символов и их разнообразие, но вот чего разработчик никогда не сможет — это заставить придумать уникальный пароль. Значит мы живём в мире где один пароль пользователя подходит к разным сервисам. Это факт. А коль скоро это факт, утечка его пароля из твоей системы вызывает последствия неопределённых масштабов, выходящие далеко за пределы тех рисков и ответственности, на которые ты мог рассчитывать.
Если тебе плевать на судьбу своих пользователей, предлагаю рассмотреть немного абсурдный, но занимательный пример.
Сами по себе логин и пароль не являются персональными данными, но ровно до тех пор, пока я не запишу туда свои паспортные данные и отправлю на сервер. Ну а что? Такой вот у меня пароль. Так уж вышло, что по нему можно однозначно идентифицировать мою личность. Как только сервер принял паспортные данные в виде пароля и пытается их захешировать, у владельца сервера тут же возникает статус оператора персональных данных со всеми вытекающими. Чувствуешь градус абсурда?
Можно ещё повысить этот градус, если передать в качестве пароля запрещённую информацию. Представь, твой сервер ночами напролёт хеширует цопэ, которое ему присылают в качестве пароля, а особо понравившееся nginx сохраняет в access‑log, а Sentry ещё и «распространяет».
Здорово, правда?
Я это к чему — сам пароль серверу для аутентификации вовсе и не нужен, и знать его — лишняя головная боль. Пароль — это «чужое». Нельзя трогать. Ай‑яй‑яй. Надеюсь, что мне удалось донести эту простую мысль.
Что делать
Главное правило — Пароль не должен покидать клиента.
Хеширование пароля на клиенте
Самый ленивый способ. Клиент отправляет hash(password + salt). Исходный пароль не светится больше нигде и никогда, но сам хеш становится эквивалентом пароля, который при утечке больше нигде кроме данного сервера использовать не получится. Необходимо применять современные алгоритмы хеширования, например Argon2, который сокращает скорость перебора до нескольких паролей в секунду. В соль нужно включить домен сервиса или уникальный идентификатор: иначе два сайта, внедрившие одинаковую схему с одинаковой солью, выдадут одинаковые хеши, и перехват на одном откроет вход на другой. Удобный вариант — соль из домена и логина, вычисляемая на клиенте:
сient_hash = Argon2id(password, salt = domain || login)
Сервер всё так же обязан хранить свой хеш от присланной строки, а не её саму, иначе кража базы даст эквиваленты паролей всех пользователей.
Для большинства пет‑проектов этого уже вполне достаточно. Это далеко не лучшее решение, но определённо лучше открытого пароля тем что закрывает главную проблему, связанную с утечками - компрометацию оригиналов паролей пользователей.
Challenge‑Response Authentication
Недостаток предыдущего метода в том, что хеш на клиенте становится эквивалентом пароля. Если злоумышленник получит этот хеш, он сможет аутентифицироваться на текущем сервере сколько угодно (replay‑атака). Этот риск был и раньше, мы его не добавили. Чтобы снизить этот риск можно реализовать трёхшаговый обмен:
Challenge. Клиент запрашивает у сервера одноразовый nonce (случайные байты). Сервер его генерирует, отдаёт и запоминает что отдал: логин пользователя, nonce, время, флаг использования — {login, nonce, timestamp, used}. Этот challenge имеет срок годности, например 1 минуту, после чего удаляется.
Response. Клиент вычисляет хеши:
сient_hash = Argon2id(password, salt = domain || login)proof_hash = Argon2id(client_hash, salt = nonce || timestamp)
и отправляет на сервер {login, timestamp, nonce, proof}. Argon2 на втором шаге можно запустить с лёгкими параметрами — данных немного, но одну итерацию прогнать стоит.Authentication. Сервер проверяет: nonce существует и не used; |now − timestamp| ≤ window; повторно хеширует хранимый client_hash с nonce || timestamp и сравнивает с proof:
expected = Argon2id(stored_client_hash, salt = stored_nonce || stored_timestamp)success = expected == proof
Чтобы исключить timing‑атаку, помечать nonce как used нужно при любом исходе проверки. а вместо посимвольного сравнения, как в псевдокоде выше, использовать функцию сравнения за постоянное время. Вычислять expected заранее на первом шаге ни в коем случае нельзя.
Как можно было заметить, сервер в этом случае вынужден хранить клиентский хеш пароля как есть, без хеширования на своей стороне. Учитывая что пароль уже захеширован на клиенте — это небольшая плата за закрытие ещё одной крупной уязвимости. Перехваченный proof бесполезен: nonce одноразовый, а timestamp делает его недолговечным, так что украденный из логов прокси или перехваченный по сети пакет годен максимум минуту.
Из минусов — украденный из базы хеш, можно использовать на этом же сервере для входа как есть.
Так шаг за шагом, можно изобретать идеальный велосипед. Но, как это обычно бывает, всё уже придумали до нас, и можно сократить путь, взяв уже готовый заводской байк последней модели из коробки.
Сначала немного теории, потом будет пара конкретных современных алгоритмов.
ZKPP
Zero‑knowledge password proof (доказательство пароля с нулевым разглашением, ZKPP). Общее название механизма, который позволяет одной стороне (доказывающей) доказать другой стороне (проверяющей), знание пароля, не раскрывая его.
Это идея обычного доказательства с нулевым разглашением (ZK), адаптированная под низкоэнтропийный секрет. В отличие от длинного ключа, пароль имеет относительно мало символов, и его можно перебрать, поэтому протокол должен быть устроен так, чтобы перехваченный обмен не позволял перебирать пароли по словарю оффлайн: каждая догадка должна требовать отдельного обращения к тому, кто может его проверить. Полностью перебор не исключить: теоретически можно стучаться на сервер и подбирать пароль бесконечно долго. Rate limit и другие технические ухищрения это ограничивают, но не исключают.
Но сам ZKPP отвечает только на вопрос «знает ли собеседник пароль». Аутентификация без выработки общего ключа почти бесполезна: она не привязана к конкретному обмену, поэтому её можно воспроизвести или перехватить с помощью man‑in‑the‑middle. Значит, нужен следующий шаг — превратить доказательство в ключ.
PAKE
Password‑Authenticated Key Exchange (PAKE) — общее название класса криптографических протоколов, в которых стороны на основе пароля вырабатывают общий высокоэнтропийный сессионный ключ. Не любой PAKE формально является ZKPP. Делятся на два типа:
Balanced PAKE — обе стороны знают один и тот же пароль (или одну и ту же его производную). В строгом смысле это не ZKPP, хотя применение у таких протоколов огромно: например, WPA3-SAE в твоём роутере. Но это не наш случай.
Augmented PAKE (aPAKE) — более подходящий для клиент‑серверных сценариев, в которых сервер хранит верификатор — уникальное производное, из которого нельзя легко восстановить пароль и которое нельзя переотправить для входа.
Любой приличный aPAKE даёт:
взаимную аутентификацию
установление сессионного ключа
защиту от офлайн‑перебора по перехваченному обмену (онлайн‑перебор остаётся и ограничивается только rate limit'ами).
Получаемый сессионный ключ нужен прежде всего самому протоколу: через него стороны подтверждают, что обмен прошёл с одним и тем же паролем именно в этой сессии, поэтому записанный обмен нельзя воспроизвести. Дальше уже сессия выдаётся как обычно (cookie с идентификатором, JWT или что ты там любишь). От кражи этой сессии PAKE не защищает. Его задача вполне конкретна: пароль и его эквивалент никогда не попадают на сервер, утечка базы не позволяет зайти, а перехваченный обмен не позволяет перебирать пароли офлайн. То что нам и было нужно.
Ниже пара современных представителей aPAKE на выбор — попроще и посложнее. Промежуточные популярные, но устаревшие протоколы я пропустил, ибо тысячи их.
SPAKE2+
RFC 9383, сентябрь 2023. Один из самых простых и актуальных aPAKE. Не путай со SPAKE2.
Каждый генерирует своё случайное число и отправляет собеседнику точку на эллиптической кривой, в которую замешано это случайное число и производное от пароля. Получив сообщение другой стороны, каждый комбинирует его со своим случайным числом и производным от пароля — и обе стороны приходят к одному и тому же общему секрету.
Модель угроз:
Перехват трафика — бесполезен. Атакующий получает одну онлайн‑попытку за сессию.
Утечка верификатора из базы — даёт оффлайн‑перебор. Верификатор математически связан с паролем, поэтому кандидата можно проверить без участия сервера.
Компрометация сервера в момент регистрации — тоже даёт оффлайн‑перебор: сервер получает верификатор.
Единственный тормоз для оффлайн перебора — стоимость вычисления производного от пароля: Argon2 с нормальными параметрами и уникальная соль для каждого пользователя. Для среднего пароля этого достаточно.
OPAQUE
Золотой стандарт. RFC 9807 от 2025 г. Рекомендован IETF CFRG. Имеет строгое криптографическое доказательство безопасности.
Клиент отправляет «ослеплённое» значение (математическая маскировка пароля случайным множителем), сервер применяет к нему свою секретную функцию и возвращает результат, не видя, что именно обработал. Секрет этой функции — OPRF‑ключ: случайный скаляр, который сервер хранит вне базы (HSM/KMS/Vault), и без которого украденные верификаторы бесполезны для перебора. Сервер участвует в вычислении производного от пароля, не получая никакой информации о пароле, даже если Sentry в порыве энтузиазма запишет весь request body со стеком. Сверху OPAQUE добавляет обмен ключами на основе тройного Диффи‑Хеллмана: клиент и сервер взаимно проверяют, что общаются друг с другом, и договариваются об общем сессионном ключе.
Модель угроз:
Перехват трафика — бесполезен.
Утечка базы верификаторов — не даёт офлайн‑перебора: в базе нет серверного секрета, а без него прогнать кандидата через OPRF нельзя.
Компрометация сервера в момент регистрации: пароль не раскрывается, сервер видит только ослеплённые значения.
Полная компрометация сервера (ключ + база): офлайн‑перебор возможен, но по одному пользователю за раз и без заранее посчитанных таблиц. Под угрозой только совсем слабые пароли.
Компрометация TLS‑сертификата сервера (привет ТСПУ+НУЦ): вход и пароль это не раскрывает, взаимная аутентификация делает MITM бесполезным. Остальной трафик — уже не в его компетенции.
Минусы этого добряка в основном связаны с серверным ключом:
Серверный секрет придётся беречь. Он должен жить в HSM/KMS/Vault, а не в базе и не в репозитории.
при деплое ключ нужно безопасно доставить в приложение (через менеджер секретов, а не «как‑нибудь через env»);
при масштабировании на несколько серверов ключ должен быть одинаковым у всех;
при ротации нужно перерегистрировать всех пользователей или поддерживать сложную схему с двумя ключами одновременно;
при компрометации ключ приходится отзывать, перерегистрировать всех пользователей и принудительно менять пароли.
Стоимость. OPRF добавляет несколько операций на эллиптической кривой, а основная нагрузка обычно приходится на Argon2 на стороне клиента. Но на очень нагруженном сервисе лишние операции на каждый вход всё же заметны.
Аутентификация без пароля
Всякие Passkeys / WebAuthn — это отдельный дивный мир. Рассматривать его в рамках этой статьи не вижу смысла.
Ещё раз
Оставить это на потом — очень плохая идея, потому что переделывать аутентификацию на существующей базе будет больнее и для тебя, и для клиентов, особенно если проект взлетит и каждый чих начнёт измеряться деньгами.
Ничего придумывать не нужно. Всё стандартизировано, существуют библиотеки. Если не хочешь вникать в «эти ваши непонятные протоколы», делай хотя бы хеширование на фронте. Это всё равно будет сильно лучше чем отправлять голый пароль.
Да и в конце концов, не руками же тебе код писать.
Спасибо за внимание к этому вопросу. Распространите среди жильцов вашего ЖЭКа.
