Хеширование паролей в Python: bcrypt, Argon2 и почему это важно
Руководство для разработчиков по безопасности паролей в Python 3: почему быстрые хеши приводят к утечкам, как на самом деле работают соли и перцы, как выбирать между Argon2id и bcrypt и как безопасно мигрировать устаревшие базы данных, не ломая вход пользователей.
- python
- безопасность
- криптография
- пароль
- backend
Хеширование паролей в Python: bcrypt, Argon2 и почему это важно#

В конечном счёте каждый бэкенд аутентификации хранит учётные данные пользователей одним из двух способов: либо в открытом виде, либо в виде хешей.
Хранить пароли в открытом виде — это чистая халатность. Одна ошибка SQL-инъекции, открытый снимок резервной копии или утекший лог APM мгновенно передаёт все учётные записи пользователей злоумышленникам на блюдечке.
Поскольку открытый текст — очевидно неприемлемый вариант, разработчики обращаются к хешам, превращая чувствительные секреты в необратимые математические дайджесты. Но здесь и кроется более тонкая и опасная ловушка: предположение, что любой хеш достаточно хорош.
Слишком многие production-системы до сих пор полагаются на наивные вызовы вроде hashlib.sha256(password.encode()).hexdigest(). Этот 64-символьный шестнадцатеричный дайджест выглядит криптографически стойким и безопасным, но современная игровая видеокарта может пересчитывать миллиарды SHA-256 в секунду. Если такая база утечёт, учётные данные ваших пользователей можно считать хранившимися в открытом виде.
Хранение паролей — единственный уголок разработки ПО, где медлительность является фичей, а не багом. Давайте разберёмся, почему быстрые хеши не подходят, какие алгоритмы действительно стоит использовать в Python и как безопасно модернизировать систему аутентификации, не разлогинивая ни одного пользователя.
⚡ Кратко за 30 секунд
- Никогда не используйте быстрые хеши (MD5, SHA-1, SHA-256, SHA-512) для учётных данных.
- Строите что-то новое? Используйте Argon2id через
argon2-cffi. Это современный золотой стандарт.- Унаследовали bcrypt? Оставьте его, но убедитесь, что коэффициент стоимости установлен на 12 или выше.
- Работаете в режиме соответствия FIPS? Используйте PBKDF2-HMAC-SHA256 минимум с 600 000 итераций.
- Соли обязательны и автоматичны в современных библиотеках. Не пишите собственную логику солей.
- Всегда используйте сравнения за постоянное время, чтобы предотвратить атаки по времени.
Почему «быстрые» хеши приведут вас к утечке#

Когда учёные проектировали SHA-256, у них была конкретная цель: проверять многогигабайтные ISO-образы и подписывать TLS-сертификаты как можно быстрее.
Одна энтузиастская видеокарта — например, NVIDIA RTX 4090 — может вычислять более 8 миллиардов SHA-256-хешей в секунду. Арендуйте кластер из восьми таких карт в AWS на один день, и злоумышленник сможет перебирать десятки миллиардов паролей-кандидатов каждую секунду.

GPU злоумышленника (SHA-256): ████████████████████ 8 500 000 000 попыток / сек
GPU злоумышленника (Argon2id): █ 1 200 попыток / сек
Для хранения паролей ваша цель — намеренное трение. Вам нужен алгоритм, который занимает около 250–500 миллисекунд на вашем production-железе.
Для реального пользователя, входящего в систему, задержка в 300 мс кажется мгновенной. Но для хакера с утекшей базой вычисление 300 мс на одну догадку превращает словарную атаку за один день в финансовый кошмар на столетия.
Хеширование vs. шифрование: путаница, которая должна умереть#

Распространённая ошибка, встречающаяся и в маркетинговых текстах, и в код-ревью у джунов: «Не волнуйтесь, мы шифруем все пароли».
Воспринимайте эту фразу как немедленный тревожный сигнал:
| Примитив | Обратим ли? | Назначение | Что произойдёт при утечке? |
|---|---|---|---|
| Шифрование (AES, RSA) | Да (через секретный ключ) | Защита данных при передаче или хранении, чтобы авторизованные стороны могли прочитать их позже. | Если злоумышленники скомпрометируют ключ или KMS, они получат все пароли в открытом виде. |
| Хеширование (Argon2, bcrypt) | Нет (односторонняя математическая ловушка) | Проверка личности без знания исходного секрета. | Злоумышленникам придётся подбирать каждый пароль отдельно перебором. |
Когда пользователь входит в систему, вы никогда не «расшифровываете» его пароль. Вы применяете ту же самую математическую формулу к введённому кандидату и проверяете, совпадают ли полученные дайджесты.
Помощники: соли и перцы простыми словами#

1. Соль: убийца «радужных таблиц»#
Если десять пользователей выберут "P@ssword123!", наивный хеш создаст десять одинаковых строк в базе. Злоумышленникам даже не нужно взламывать их по одному; они просто предвычисляют хеши для миллионов популярных паролей (радужная таблица) и мгновенно находят совпадение.
Соль — это просто криптографически стойкая случайная строка, добавляемая к паролю перед хешированием:
$дайджест = Хеш(пароль + соль)$
- Уникальна для каждого пользователя: Алиса и Боб могут использовать один и тот же ужасный пароль, но из-за разных солей их сохранённые хеши выглядят совершенно не связанными.
- Не секрет: соль хранится в открытом виде рядом с хешем в вашей базе.
- Автоматически: современные библиотеки генерируют, форматируют и разбирают соли за вас. Вам не нужен столбец
saltв SQL-схеме.
2. Перец: дополнительный сейф за стойкой#
Если злоумышленник выгрузит вашу SQL-базу через уязвимость инъекции, он получит и хеши, и их соли. Офлайн-взлом теперь может начаться.
Перец — это секретный ключ уровня приложения, подмешиваемый в хеш и хранящийся вне базы данных (например, в AWS Secrets Manager, Vault или переменных окружения).
[ Ввод пользователя ] + [ Соль (из БД) ] + [ Перец (из Env/Vault) ] ──▶ [ Хеш ]
Если ваша база утечёт без секретов сервера приложения, хеши будут практически невозможно взломать, потому что у злоумышленника отсутствует целый компонент формулы.
💡 Практический совет: Внедряйте перец только если у вашей команды есть зрелое управление ключами. Если вы потеряете перец, каждый пользователь навсегда потеряет доступ. Правильно настроенный хеш Argon2id с сильной солью уже обеспечивает защиту мирового уровня для большинства приложений.
Претенденты: выбираем алгоритм#
Вы создаёте совершенно новое приложение?
├── ДА ──▶ Используйте Argon2id (pip install argon2-cffi)
└── НЕТ
├── Уже используете bcrypt? ──▶ Оставьте, но поднимите work factor до 12+
└── Строгие государственные/FIPS-требования? ──▶ PBKDF2-HMAC-SHA256 (600 000+ раундов)
1. Argon2id: бесспорный король#
Argon2 победил в строгом многолетнем Password Hashing Competition в 2015 году и стандартизирован в RFC 9106.
Его суперспособность — устойчивость к памяти. GPU быстры, потому что у них тысячи крошечных ядер, выполняющих простую математику параллельно. Argon2id заставляет железо выделять значительный кусок RAM (например, 64 МиБ) для каждого потока хеширования. У высококлассной GPU с 24 ГБ видеопамяти внезапно заканчивается место уже после нескольких сотен одновременных потоков, что нейтрализует аппаратное преимущество злоумышленника.
pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
# Настраиваем production-параметры стоимости
ph = PasswordHasher(
time_cost=3, # 3 итерации
memory_cost=65536, # 64 МиБ использования RAM
parallelism=4, # 4 параллельных потока CPU
hash_len=32,
salt_len=16,
)
raw_password = "correct horse battery staple"
# 1. Хеширование: генерация соли и кодирование параметров происходят автоматически
hashed = ph.hash(raw_password)
print(hashed)
# Вывод выглядит так:
# $argon2id$v=19$m=65536,t=3,p=4$q7tW4j...$O1WkZ9...
# [алгоритм] [версия] [память, время, потоки] [соль] [хеш]
# 2. Проверка
try:
ph.verify(hashed, raw_password)
print("✅ Доступ разрешён.")
except VerifyMismatchError:
print("❌ Неверный пароль.")
except (VerificationError, InvalidHashError):
print("⚠️ Некорректная запись хеша.")
# 3. Прозрачное обновление параметров
# Если через 2 года вы обновите сервер и увеличите memory_cost,
# этот метод проверит, нуждается ли существующий хеш в тихом пересчёте.
if ph.check_needs_rehash(hashed):
new_hash = ph.hash(raw_password)
# db.save_password_hash(user_id, new_hash)
🐳 Production-подводный камень: OOM-убийства в Docker и Kubernetes
Если ваш сервис работает в контейнере со строгим лимитом памяти (например, 512 МиБ) и использует memory_cost 64 МиБ (65,536 КиБ), десять одновременных запросов на вход потребуют более 640 МиБ RAM мгновенно. Ядро Linux вызовет OOM Killer и уронит контейнер приложения.
Если вы ограничены по памяти:
- Ограничьте скорость или поставьте в очередь попытки входа на обратном прокси (Nginx, Traefik).
- Или уменьшите
memory_costдо 19–32 МиБ и слегка увеличьтеtime_cost, чтобы сохранить общую длительность работы.
2. bcrypt: надёжный боевой топор#
Выпущенный в 1999 году, bcrypt защищает production-нагрузки уже более четверти века без каких-либо критических теоретических взломов.
bcrypt использует логарифмический коэффициент стоимости. Увеличение стоимости на +1 удваивает время выполнения:
- Стоимость
10= 210 = 1,024 итерации - Стоимость
12= 212 = 4,096 итераций (рекомендуемый базовый уровень) - Стоимость
14= 214 = 16,384 итерации
pip install bcrypt
import bcrypt
password = b"my-super-secure-passphrase"
# Хеш: rounds=12 занимает примерно 250-350 мс на современных CPU
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password, salt)
print(hashed) # b'$2b$12$e8y...U5j.'
# Проверка: bcrypt.checkpw выполняет проверку за постоянное время под капотом
if bcrypt.checkpw(password, hashed):
print("✅ Аутентификация пройдена!")
else:
print("❌ Неверные учётные данные.")
⚠️ Ловушка 72 байт (и как обрабатывать длинные парольные фразы)
У bcrypt есть жёсткое архитектурное ограничение, унаследованное от шифра Blowfish: он молча игнорирует всё после 72-го байта. В стандартном bcrypt "a" * 72 + "secret1" и "a" * 72 + "secret2" дают одинаковые хеши. (Примечание: недавние версии Python-библиотеки bcrypt выбрасывают ValueError вместо молчаливого усечения).
Если ваши пользователи генерируют 100-символьные парольные фразы, сначала хешируйте их через SHA-256 и кодируйте результат в Base64, прежде чем передавать в bcrypt:
import base64
import hashlib
import bcrypt
def hash_long_password(password_bytes: bytes) -> bytes:
# 1. Сжимаем бесконечную длину до безопасных фиксированных 32 байт
digest = hashlib.sha256(password_bytes).digest()
# 2. Кодируем в Base64, чтобы убрать нулевые байты (которые завершают C-строки)
safe_token = base64.b64encode(digest)
# 3. Безопасно хешируем через bcrypt (всегда <= 44 символов)
return bcrypt.hashpw(safe_token, bcrypt.gensalt(rounds=12))3. PBKDF2: галочка для соответствия требованиям#
PBKDF2 не является memory-hard, поэтому его значительно легче взломать на специализированных ASIC/GPU-стендах, чем Argon2 или bcrypt. Однако у него есть один явный козырь: сертификация FIPS-140.
Если вы работаете в сфере федеральных контрактов США, обороны или некоторых банковских секторах, требования соответствия часто отвергают Argon2 и требуют PBKDF2.
Python предоставляет PBKDF2 из коробки в стандартной библиотеке:
import hashlib
import hmac
import os
import secrets
password = "enterprise-compliant-password".encode()
salt = secrets.token_bytes(16)
# OWASP сейчас рекомендует >= 600 000 итераций для HMAC-SHA256
iterations = 600_000
key = hashlib.pbkdf2_hmac(
hash_name="sha256",
password=password,
salt=salt,
iterations=iterations,
dklen=32,
)
# Проверка ОБЯЗАНА использовать hmac.compare_digest для предотвращения атак по времени!
candidate_input = "enterprise-compliant-password".encode()
test_key = hashlib.pbkdf2_hmac("sha256", candidate_input, salt, iterations, dklen=32)
if hmac.compare_digest(key, test_key):
print("✅ Совпадение")
Ошибки, которые будут преследовать вашу production-систему#
1. Наивные сравнения строк (==)#
# ❌ ОПАСНО: прерывается на первом несовпавшем байте!
# Злоумышленники измеряют время ответа в микросекундах, чтобы восстановить хеш.
if submitted_hash == stored_hash:
...
# ✅ БЕЗОПАСНО: сравнение за постоянное время
import hmac
if hmac.compare_digest(submitted_hash, stored_hash):
...
2. Логирование тела запроса при неудачных входах#
Когда клиент опечатывается и вводит пароль в поле имени пользователя, отладочные логгеры, настроенные на запись сырого payload, запишут пароли в открытом виде прямо в Datadog, CloudWatch или Splunk. Очищайте auth-payload перед логированием.
3. Изобретение собственных схем солей#
Вам не нужно писать salt = str(uuid4()) и создавать отдельный SQL-столбец. Форматы вроде $argon2id$v=19$... и $2b$12$... — это стандартизированные строки Modular Crypt Format (MCF), содержащие соль, параметры и дайджест. Храните эту одну строку в одном столбце.
Миграция без простоя: обновление устаревших хешей#
Что, если вы только что унаследовали старое Django- или Flask-приложение, хранящее несо́ленные MD5- или SHA-256-хеши?
Вы не можете декодировать старые пароли — в этом и смысл хеша. Но вы можете выполнить ленивое перехеширование: тихо обновлять хеш каждого пользователя в фоне ровно в момент его входа.
import hashlib
import hmac
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
def check_password_and_upgrade(stored_record: str, submitted_pw: str) -> tuple[bool, str | None]:
"""
Проверяет пароль пользователя.
Возвращает: (is_authenticated, new_hash_to_persist_or_None)
"""
pw_bytes = submitted_pw.encode("utf-8")
# Сценарий A: современный Argon2id
if stored_record.startswith("$argon2id$"):
try:
ph.verify(stored_record, submitted_pw)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False, None
# Проверяем, был ли хеш создан со старыми, более слабыми параметрами стоимости
rehashed = ph.hash(submitted_pw) if ph.check_needs_rehash(stored_record) else None
return True, rehashed
# Сценарий B: устаревший SHA-256 (формат: "sha256:<salt_hex>:<hash_hex>")
if stored_record.startswith("sha256:"):
_, salt_hex, digest_hex = stored_record.split(":")
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(digest_hex)
candidate = hashlib.sha256(salt + pw_bytes).digest()
if not hmac.compare_digest(candidate, expected):
return False, None
# Успех! Прозрачно обновляем пользователя до Argon2id
print("🔄 Обновляем устаревшего пользователя до Argon2id...")
return True, ph.hash(submitted_pw)
# Неизвестный формат хеша
return False, None
# Безопасный маршрут входа
def handle_login(username: str, candidate_pw: str) -> bool:
user = db.find_user(username)
if not user:
# СМЯГЧЕНИЕ: предотвращаем перечисление пользователей через атаки по времени
# Если пользователя не существует, всё равно вычисляем фиктивный хеш,
# чтобы время ответа совпадало с легитимной попыткой аутентификации (~300 мс).
ph.hash(candidate_pw)
return False
is_valid, new_hash = check_password_and_upgrade(user.password_hash, candidate_pw)
if is_valid:
if new_hash:
db.update_user_hash(user.id, new_hash)
return True
return False
Проверьте сами: интерактивная площадка для хешей#
Хотите поэкспериментировать с этими алгоритмами без написания шаблонного кода? Попробуйте интерактивную площадку для хеширования паролей в браузере.
Всё выполняется на 100% локально в вашем браузере через WebAssembly (никакие пароли или учётные данные никогда не передаются по сети). Вы можете:
- Генерировать и изучать сырые строки Argon2id vs. bcrypt.
- Менять затраты памяти и количество итераций, чтобы увидеть, как они влияют на время выполнения.
- Проверять форматы хешей на соответствие выводу вашего Python-бэкенда.
Аутентификация — это не про то, чтобы самостоятельно перехитрить математически сложные задачи; это про выбор скучных, устоявшихся стандартов и позволение проверенным в боях библиотекам делать тяжёлую работу. Выберите Argon2id, настройте реалистичные ограничения ресурсов, и пароли ваших пользователей останутся в безопасности, даже если ваша база попадёт не в те руки.