Хранение паролей в веб-приложении: схема без ошибок
Пароль нельзя хранить в базе как строку или шифровать обратимым способом. Разбираем, как выбрать алгоритм, настроить соль и параметры, пережить утечку базы и безопасно обновлять хеши.

Утечка таблицы пользователей не должна превращать базу в список готовых паролей. Для этого пароль хранят не в открытом виде и не под обратимым шифрованием, а пропускают через медленный адаптивный алгоритм: Argon2id, scrypt, bcrypt или PBKDF2. Ниже — практическая схема для веб-приложения, критерии выбора и пример на Python 3.11+ со стандартной библиотекой.
Главное правило: пароль нельзя расшифровать
Шифрование подходит для данных, которые приложению нужно восстановить: например, для токена внешнего сервиса. Пароль восстанавливать не требуется. Во время входа приложение получает введённую строку, вычисляет из неё хеш с теми же параметрами и сравнивает результат с записью в базе.
Быстрые хеши вроде SHA-256, SHA-512 и MD5 для паролей не подходят. Они созданы для быстрой проверки целостности, поэтому атакующий может перебирать огромное количество вариантов на видеокартах. Парольный алгоритм, напротив, должен намеренно тратить время и память на каждый вариант.
Цель защиты — не сделать перебор математически невозможным, а сделать каждую попытку настолько дорогой, чтобы массовый перебор перестал окупаться.
Из чего состоит правильная схема
Алгоритм и параметры
Для нового проекта практичный выбор — Argon2id или scrypt. Оба алгоритма используют не только вычисления, но и память. Это усложняет атаку на специализированном оборудовании. bcrypt остаётся распространённым вариантом для старых систем, но у него есть ограничение: исходный пароль обычно обрабатывается только в пределах 72 байт. Для новых систем это нужно учитывать заранее.
Если доступен только PBKDF2, используйте его с HMAC-SHA-256 и большим числом итераций, сверенным по актуальным рекомендациям и производительности вашего сервера. Точное значение нельзя переносить в проект вслепую: оно зависит от версии библиотек и допустимой задержки входа.
Уникальная соль на каждую запись
Соль1 — случайное значение, которое добавляется к паролю перед вычислением хеша. Соль не является секретом и хранится рядом с хешем. Её задача — не дать атакующему использовать одну заранее рассчитанную таблицу для пользователей с одинаковыми паролями.
Соль должна генерироваться криптографически стойким генератором случайных чисел. Нельзя брать логин, email, дату регистрации или одну общую строку для всех пользователей. Современные библиотеки часто сами создают и сохраняют соль в закодированном результате, но это нужно проверить в документации конкретной версии.
Секретный pepper — только как дополнительный слой
Pepper2 — общий секрет приложения, который не хранится в базе. Он может находиться в менеджере секретов или защищённой переменной окружения. Если база утекла, pepper мешает немедленному офлайн-перебору, но создаёт новую точку отказа: при его потере все пароли придётся сбрасывать, а при утечке вместе с базой преимущество исчезает.
Не заменяйте pepper солью. Соль публична и индивидуальна, pepper секретен и общий для приложения. Сначала настройте корректный алгоритм, параметры и права доступа к базе, а затем решайте, нужен ли дополнительный секрет.
Какой подход выбрать
| Подход | Когда использовать | Цена и ограничения |
|---|---|---|
| Argon2id | Новый проект, если есть поддержка проверенной библиотекой | Расходует память и CPU; параметры нужно измерить на сервере |
| scrypt | Новый проект без удобной поддержки Argon2id или при уже принятом стандарте | Требует настройки памяти; слишком высокий параметр может исчерпать ресурсы при массовом входе |
| bcrypt | Совместимость со старым стеком и существующей базой | Ограничение длины входа и более узкие возможности настройки памяти |
| PBKDF2-HMAC-SHA-256 | Среда с FIPS-требованиями или ограниченным набором библиотек | Сильнее зависит от числа итераций; быстрый перебор требует особенно тщательной настройки |
Для нового приложения выбирайте алгоритм, который поддерживает официальная или широко используемая библиотека вашего стека, умеет генерировать соль и хранит параметры вместе с результатом. Не пишите криптографию самостоятельно.
Рабочий вариант на Python: scrypt
Ниже — самостоятельный пример для Python 3.11 и новее. Он использует hashlib.scrypt, secrets.token_bytes и постоянное по времени сравнение из стандартной библиотеки. В базе можно хранить одну строку формата scrypt$n=131072,r=8,p=1$соль$хеш.
import base64
import hashlib
import hmac
import secrets
SCRYPT_N = 2 ** 17
SCRYPT_R = 8
SCRYPT_P = 1
KEY_LENGTH = 32
def encode_base64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def decode_base64(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + padding)
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
derived_key = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=SCRYPT_N,
r=SCRYPT_R,
p=SCRYPT_P,
dklen=KEY_LENGTH,
)
return (
f"scrypt$n={SCRYPT_N},r={SCRYPT_R},p={SCRYPT_P}"
f"${encode_base64(salt)}${encode_base64(derived_key)}"
)
def verify_password(password: str, stored_value: str) -> bool:
algorithm, parameters, salt_text, key_text = stored_value.split("$")
if algorithm != "scrypt":
return False
values = dict(item.split("=") for item in parameters.split(","))
derived_key = hashlib.scrypt(
password.encode("utf-8"),
salt=decode_base64(salt_text),
n=int(values["n"]),
r=int(values["r"]),
p=int(values["p"]),
dklen=len(decode_base64(key_text)),
)
return hmac.compare_digest(derived_key, decode_base64(key_text))
Перед продакшеном измерьте время одной проверки на том же типе сервера, где работает приложение. Значение n=2**17 требует заметного объёма памяти на один вызов; при большом числе параллельных входов это нужно умножить на число одновременно выполняющихся проверок.
Что обычно ломают при реализации
- Хранят пароль или обратимое шифрование. Исправление — перейти на парольный KDF и заставить пользователей сменить пароль. Старый пароль нельзя «перехешировать» без знания его исходного значения.
- Используют одну соль для всех. Сгенерируйте новую случайную соль при каждой установке или смене пароля. Смена логина не должна считаться сменой соли.
- Сравнивают строки обычным оператором. Для байтов хеша используйте функцию постоянного времени, которую предоставляет ваш стек, например
hmac.compare_digestв Python. - Придумывают собственную схему. Конструкции вроде «SHA-256 десять раз», добавление email к паролю или ручное перемешивание строк не дают проверяемых гарантий.
- Логируют секреты. Пароль, результат хеширования, токен сброса и тело запроса не должны попадать в логи, трассировки, аналитику и сообщения об ошибках.
Обновление параметров без массового сброса
Параметры KDF со временем устаревают, а серверы становятся быстрее. Поэтому формат записи должен хранить название алгоритма и его параметры. При успешном входе приложение может проверить, соответствует ли запись текущей политике. Если нет, оно вычисляет новый хеш из пароля, который пользователь только что ввёл, и заменяет старую запись.
Это называют постепенной миграцией хешей. Она не требует знать старые пароли и не создаёт одномоментную нагрузку на всех пользователей. Для аккаунтов, которые давно не входили, остаётся принудительный сброс через безопасный одноразовый процесс.
Сброс пароля и защита входа
Хороший хеш не спасёт приложение, если восстановление аккаунта сделано слабее. Ссылка сброса должна содержать случайный одноразовый токен с ограниченным сроком действия. В базе храните не сам токен, а его отдельный хеш, чтобы утечка таблицы не давала возможность сразу использовать активные ссылки.
После смены пароля отзовите активные сессии или предложите пользователю завершить их. Ограничивайте частоту попыток входа и сброса, добавьте CAPTCHA или дополнительную проверку там, где это оправдано, но не блокируйте аккаунт навсегда после нескольких ошибок: такая схема превращается в инструмент отказа в обслуживании.
Где подход ломается
Слишком высокий расход памяти
Параметры, которые безопасны для одного ручного входа, могут перегрузить сервис при сотнях параллельных запросов. Нагрузочный тест должен включать не только успешный вход, но и неверные пароли: именно они обычно обходятся без кэширования и могут быть отправлены массово.
Слишком длинный пароль
Не обрезайте пароль молча. Если выбран bcrypt, заранее изучите ограничение версии библиотеки и способ обработки многобайтных строк. Для современных систем лучше принять длинные парольные фразы и явно определить максимальный размер входного значения, чтобы защита не превратилась в потребление гигабайтов памяти.
Несколько серверов с разной политикой
Если часть инстансов считает старые параметры допустимыми, миграция будет непредсказуемой. Вынесите политику хеширования в версионируемую конфигурацию, одинаковую для всех экземпляров, и отдельно контролируйте ошибки нехватки памяти.
Что делать дальше
Сначала проверьте схему пользователя: в ней должны быть поле для полного кодированного хеша, время его создания или обновления и признаки необходимости миграции. Затем проведите тест на реальном типе сервера: измерьте задержку проверки, потребление памяти и максимальное число параллельных попыток, которое выдерживает отдельный процесс.
После этого проверьте аудит: пароли и токены не должны появляться в логах, восстановление должно отзывать старые сессии, а политика алгоритма — иметь версию. Инвалидация сессий, двухфакторная аутентификация и защита от подбора — отдельные темы; здесь важно сначала закрыть базовый риск хранения.
1 Соль — публичное случайное значение, уникальное для конкретного хеша пароля.
2 Pepper — дополнительный общий секрет приложения, который не хранится вместе с базой пользователей.
Генератор изображений: Иллюстрация, обложка, баннер, схема — по текстовому описанию, без исходного фото.
Спросить за 20 ₽Источники: OWASP Password Storage Cheat Sheet, Python documentation: hashlib.scrypt
Частые вопросы
Можно ли хранить пароль в виде SHA-256?
Нужно ли шифровать хеш пароля ключом приложения?
Что делать, если в базе уже лежат открытые пароли?
Что делать, если выбранный алгоритм оказался слишком медленным?
Можно ли использовать эту схему на другой версии Python или в другом языке?
Нужно ли заставлять всех пользователей менять пароль после усиления параметров?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.