Хеширование паролей нужно, чтобы в базе данных лежал не сам пароль и не его шифр, а хеш медленной функции с солью: Argon2id, scrypt, bcrypt или PBKDF2. При входе сервер считает хеш введённого пароля с той же солью и настройками и сравнивает результат с записью. Быстрые MD5 и SHA-256 для этого не годятся даже с солью. Код ниже прогнан на Python 3.14.3 (Windows 10, OpenSSL 3.0.18) с пакетами argon2-cffi 25.1.0 и bcrypt 5.0.0, вывод настоящий.
- Зачем хешировать пароли и почему не шифровать
- Почему MD5 и SHA-256 не подходят для паролей
- Соль: случайная строка для каждого пароля
- Какой алгоритм хеширования паролей выбрать
- Argon2id в Python
- bcrypt и лимит 72 байта
- PBKDF2 и scrypt из стандартной библиотеки
- Как подобрать настройки: замер на своём сервере
- Как проверяется пароль при входе
- Перец: секрет вне базы данных
- Перехеширование и миграция старых хешей MD5
- Частые вопросы
- Можно ли расшифровать хеш пароля?
- Где хранить соль?
- Сколько раундов bcrypt ставить?
- Какую длину пароля разрешать?
Зачем хешировать пароли и почему не шифровать
Таблица пользователей может утечь через SQL-инъекцию или забытый бэкап базы данных. Если пароли хранятся в открытом виде, злоумышленник сразу получает доступ ко всем аккаунтам, а у тех, кто ставит один пароль везде, и к учётным записям на других сайтах.
Шифрование вместо хеширования эту проблему не решает. Это двусторонняя операция: у кого есть ключ, тот восстановит исходные данные. Хеш-функция работает в одну сторону: из хеша пароль не вычислить, его можно только угадать, перебирая варианты. Серверу исходный пароль и не нужен, для проверки достаточно сравнить два хеша. Общие свойства хеш-функций (строка фиксированной длины на выходе, лавинный эффект, коллизии) разобраны в статье что такое хеш-функция.
Почему MD5 и SHA-256 не подходят для паролей
Криптографические хеш-функции общего назначения вроде MD5 и SHA-256 считаются быстро. Для хеширования паролей это плохо: та же скорость позволяет злоумышленнику проверять очень много вариантов при атаке по словарю. Вторая проблема видна на трёх пользователях:
import hashlib
for user, password in [("anna", "sunrise7"), ("oleg", "sunrise7"), ("ivan", "Sunrise7")]:
print(user, hashlib.sha256(password.encode()).hexdigest()[:16])
anna b9d3afe2cf79d92e
oleg b9d3afe2cf79d92e
ivan 53c060860bf99b62
Алгоритм без соли всегда даёт одинаковый хеш для одного пароля, поэтому по дампу базы данных сразу видно, что у двух пользователей пароль общий. Хеши популярных паролей можно посчитать заранее и потом просто искать в готовых базах: так работают таблицы готовых хешей, в том числе радужные. У MD5 и SHA-1 вдобавок известны коллизии, но для хранения паролей все три алгоритма не годятся уже из-за скорости.
Мы бы не советовали хранить sha256(соль + пароль) даже в учебном проекте. Соль закрывает заранее посчитанные таблицы, а скорость перебора остаётся прежней.

Соль: случайная строка для каждого пароля
Солью называют уникальную случайную строку, которую добавляют к каждому паролю перед хешированием. У двух пользователей с одинаковым паролем соли разные, значит, и хеши разные: таблицу под каждую соль заранее не посчитать, а подбирать пароль приходится для каждой учётной записи отдельно.
Соль не секрет, её хранят рядом с хешем, иначе сервер не сможет проверить пароль. Соль берите не короче 32 бит, а лучше 128 бит (16 байт). Источник случайных данных нужен криптостойкий: os.urandom() или модуль secrets; модуль random для этого не предназначен.
Вручную соль почти не генерируют. Argon2-cffi, bcrypt и большинство распространённых библиотек создают её сами и кладут в строку хеша. Из стандартной библиотеки соль вручную генерируют для hashlib.pbkdf2_hmac() и hashlib.scrypt().

Какой алгоритм хеширования паролей выбрать
Хорошая функция хеширования паролей настраивается, работает медленно и использует соль. Argon2 и scrypt вдобавок требуют много памяти, а такие функции дают дополнительную защиту от перебора на специализированном оборудовании.
| Алгоритм | Когда брать | Минимальные настройки | В Python |
|---|---|---|---|
| Argon2id | новый проект | m=19456 (19 МиБ), t=2, p=1 | пакет argon2-cffi |
| scrypt | Argon2id недоступен | n=2^17 (128 МиБ), r=8, p=1 | hashlib.scrypt() |
| bcrypt | уже стоит в старой системе | rounds не меньше 10 | пакет bcrypt |
| PBKDF2-HMAC-SHA256 | нужна сертификация FIPS-140 | 600 000 итераций | hashlib.pbkdf2_hmac() |
У Argon2id есть равноценные наборы: чем меньше памяти, тем больше итераций, например m=12288 (12 МиБ) с t=3 или m=7168 (7 МиБ) с t=5, все с p=1. У scrypt так же: n=2^16 с p=2, n=2^15 с p=3. PBKDF2 на SHA-512 хватит 220 000 итераций, на SHA-1 нужно 1 400 000, но вариант с SHA-1 оставляют только для старых систем.
Для нового приложения берите алгоритм хеширования Argon2id. Если ставить сторонние пакеты нельзя, scrypt из стандартной библиотеки. Bcrypt оставьте там, где он уже работает, а PBKDF2 там, где требование сертификации не обойти.
Argon2id в Python
Пакет ставится командой pip install argon2-cffi. Класс PasswordHasher по умолчанию использует Argon2id с t=3, m=65536 КиБ (64 МиБ), p=4, солью 16 байт и хешем 32 байта: это рекомендуемый набор для серверов с ограниченной памятью.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
hasher = PasswordHasher() # Argon2id, настройки по умолчанию
first, second = hasher.hash("sunrise7"), hasher.hash("sunrise7")
print(first)
print("хеши равны:", first == second)
for attempt in ("sunrise7", "sunrise8"):
try:
print(attempt, hasher.verify(first, attempt))
except VerifyMismatchError:
print(attempt, False)
$argon2id$v=19$m=65536,t=3,p=4$0VKt62DuPvWVbEucBgEh8w$FKVSsD1nTr/GbxiCeJaT/hTb3oreJhwOQVkLF/cVaZA
хеши равны: False
sunrise7 True
sunrise8 False
Один и тот же пароль дал два разных хеша: у каждого своя соль. Строка хеша содержит всё для проверки: алгоритм, версию, настройки, соль (22 символа base64) и само значение хеша (43 символа). В базе данных хватит одного текстового поля, отдельная колонка для соли не нужна.
При неверном пароле verify() бросает VerifyMismatchError, его и ловим. Остальные исключения, например InvalidHashError для испорченной строки в базе, мы бы не глушили: такую запись нужно увидеть в логе.
bcrypt и лимит 72 байта
Пакет bcrypt тоже хранит соль и число раундов в одной строке с хешем, это видно по началу $2b$12$.
import bcrypt
hashed = bcrypt.hashpw(b"sunrise7", bcrypt.gensalt()) # 12 раундов по умолчанию
print(hashed.decode())
print(bcrypt.checkpw(b"sunrise7", hashed), bcrypt.checkpw(b"sunrise8", hashed))
long_password = ("я" * 37).encode()
print(len(long_password), "байта")
try:
bcrypt.hashpw(long_password, bcrypt.gensalt())
except ValueError as error:
print("ValueError:", error)
$2b$12$cp2Jzbpd0VKgDuITEFEOaO.E8Cq/niaoepAuUkWdaxvd6kXWe3ijy
True False
74 байта
ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
Bcrypt принимает не больше 72 байт. Русская буква в UTF-8 занимает 2 байта, поэтому пароль из 37 букв «я» уже не проходит. С версии 5.0.0 пакет бросает на таком пароле ValueError, а более ранние версии, по описанию изменений версии 5.0.0, молча отрезали хвост. Длину проверяйте в байтах (len(password.encode()) <= 72) ещё при регистрации.
Обходить лимит предварительным SHA-256 опасно: исходный bcrypt останавливается на нулевом байте, а в двоичном хеше он может встретиться. Проще взять Argon2id: в нашем прогоне argon2-cffi принял и проверил пароль из 1000 букв «я».
PBKDF2 и scrypt из стандартной библиотеки
Без сторонних пакетов в Python есть две медленные функции хеширования: hashlib.scrypt() и hashlib.pbkdf2_hmac(). Соль для них берите из os.urandom(16), а рядом с хешем храните соль и число итераций, иначе после смены настроек старые записи не проверить. Хеши сравнивайте через hmac.compare_digest(): в отличие от ==, функция не обрывает сравнение на первом несовпадении, чтобы время ответа не выдавало содержимое. Полный пример со scrypt и ловушкой maxmem (с n=2^17 и r=8 вызов без него падает с ValueError ... memory limit exceeded) есть в статье о хеш-функциях.

Как подобрать настройки: замер на своём сервере
Настройки хеширования подбирают на той машине, где будет работать проверка пароля: как можно выше, но чтобы вход не тормозил. Ориентир по времени: хеш одного пароля должен считаться меньше секунды. Каждый алгоритм замерен на Intel Core i9-9900KF, взят лучший из пяти вызовов:
import hashlib, os, timeit
import bcrypt
from argon2 import PasswordHasher
password, salt = b"sunrise7", os.urandom(16)
tests = {
"Argon2id m=19456,t=2,p=1": lambda: PasswordHasher(2, 19456, 1).hash(password),
"Argon2id m=65536,t=3,p=4": lambda: PasswordHasher().hash(password),
"scrypt n=2**17,r=8,p=1": lambda: hashlib.scrypt(
password, salt=salt, n=2**17, r=8, p=1, maxmem=2**28),
"bcrypt 12": lambda: bcrypt.hashpw(password, bcrypt.gensalt(12)),
"bcrypt 13": lambda: bcrypt.hashpw(password, bcrypt.gensalt(13)),
"PBKDF2-SHA256 600 000": lambda: hashlib.pbkdf2_hmac("sha256", password, salt, 600_000),
}
for name, func in tests.items():
print(f"{name}: {min(timeit.repeat(func, number=1, repeat=5)) * 1000:.0f} мс")
Argon2id m=19456,t=2,p=1: 22 мс
Argon2id m=65536,t=3,p=4: 46 мс
scrypt n=2**17,r=8,p=1: 387 мс
bcrypt 12: 189 мс
bcrypt 13: 373 мс
PBKDF2-SHA256 600 000: 373 мс
На вашей машине цифры будут другими. Память считайте вместе со временем: с настройками по умолчанию один хеш занимает 64 МиБ, и 100 одновременных входов займут около 6,25 ГиБ. Число раундов у bcrypt задаёт степень двойки: в замере шаг с 12 на 13 удвоил время.
Как проверяется пароль при входе
- Пользователь отправляет логин и пароль.
- Сервер достаёт из базы данных строку хеша этого пользователя.
- Библиотека берёт из строки алгоритм, настройки и соль, считает хеш введённого пароля и сравнивает с сохранённым.
- Совпало: сервер открывает сессию или выдаёт токен. Не совпало: возвращает ошибку.
- Если настройки в строке хеша устарели, сервер пересчитывает хеш, пока пароль в памяти, и записывает новый.
Перец: секрет вне базы данных
Перцем называют общий для всех паролей секретный ключ, который хранят отдельно от базы данных: в хранилище секретов или аппаратном модуле (HSM). Это второй уровень защиты на случай, когда злоумышленник смог получить только базу, например через SQL-инъекцию или украденный бэкап. Пароль сначала проходит через HMAC-SHA256 с перцем в роли ключа, потом результат хешируется медленным алгоритмом. С bcrypt этот приём не повторяйте: в выходе HMAC бывают нулевые байты.
Перец генерируйте модулем secrets (secrets.token_bytes(32)), как пароли в генераторе паролей на Python. Сменить его можно только вместе с паролями всех пользователей: пересчитать хеш без исходного пароля невозможно. Потеряете перец, и войти не сможет никто. Храните его так же бережно, как ключ подписи JWT-токенов, и не кладите в репозиторий.

Перехеширование и миграция старых хешей MD5
Настройки со временем поднимают, потому что оборудование становится мощнее и дешевле. Исходного пароля у сервера нет, поэтому пересчитать хеш можно только в момент входа. Для этого у PasswordHasher есть check_needs_rehash(): он сравнивает настройки из строки хеша с настройками объекта.
Со старой базой данных, где лежит md5(пароль), ждать входа каждого пользователя опасно: кто не заходит годами, так и останется на MD5. Лучше сразу обернуть все старые хеши в медленный алгоритм одним скриптом, исходный пароль для этого не нужен. Так алгоритм хеширования меняется без сброса паролей, а при следующем входе запись переходит на чистую схему. Функция входа ниже собирает всё вместе: перец, обёрнутый MD5 у anna и старые настройки у oleg.
import hashlib, hmac, os
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
hasher = PasswordHasher() # новые настройки: m=65536, t=3, p=4
old_hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
# В проде перец берут из хранилища секретов, здесь для примера из окружения.
PEPPER = os.environ.get("PASSWORD_PEPPER", "dev-pepper").encode()
def peppered(text):
return hmac.new(PEPPER, text.encode(), hashlib.sha256).digest()
def md5(text):
return hashlib.md5(text.encode()).hexdigest()
users = {
"anna": "md5:" + hasher.hash(peppered(md5("sunrise7"))), # обёрнутый MD5
"oleg": old_hasher.hash(peppered("river42")), # старые настройки
}
def login(user, password):
stored = users[user]
legacy = stored.startswith("md5:")
try:
hasher.verify(stored.removeprefix("md5:"),
peppered(md5(password) if legacy else password))
except VerifyMismatchError:
return False # неверный пароль запись не трогает
if legacy or hasher.check_needs_rehash(stored):
users[user] = hasher.hash(peppered(password))
return True
print("oleg до:", users["oleg"][:30])
print("anna неверный:", login("anna", "sunrise8"), users["anna"][:12])
print("anna верный:", login("anna", "sunrise7"), users["anna"][:12])
print("oleg верный:", login("oleg", "river42"), users["oleg"][:30])
try:
hasher.verify(users["anna"], "sunrise7") # дамп базы без перца
print("без перца: подходит")
except VerifyMismatchError:
print("без перца: не подходит")
oleg до: $argon2id$v=19$m=19456,t=2,p=1
anna неверный: False md5:$argon2i
anna верный: True $argon2id$v=
oleg верный: True $argon2id$v=19$m=65536,t=3,p=4
без перца: не подходит
Метка md5: говорит коду, что пароль сначала нужно прогнать через MD5. Запись в users меняется только после успешной проверки, поэтому неверный пароль её не трогает. Без перца проверка не проходит: в базе лежит хеш от HMAC с перцем, и голый пароль с ним не совпадает.
Обёртка слабее чистого Argon2id, держите её временной мерой. Тем, кто не входил очень долго, хеши можно удалить и потребовать сброс пароля при следующем входе.

Частые вопросы
Можно ли расшифровать хеш пароля?
Нет: хеш не шифр, ключа к нему нет. Пароль по хешу находят только перебором или по таблицам заранее посчитанных хешей. Соль убирает таблицы, а медленный алгоритм хеширования делает дорогой каждую попытку перебора. Слабый пароль из словаря всё равно найдут первым.
Где хранить соль?
Рядом с хешем, в той же записи базы данных. Argon2-cffi и bcrypt кладут соль прямо в строку хеша. Секретом соль не считается, секретом бывает перец, и его храните вне базы.
Сколько раундов bcrypt ставить?
Не меньше 10, а по умолчанию пакет ставит 12. В нашем замере 12 раундов заняли 189 мс, 13 раундов 373 мс, то есть вдвое дольше. Поднимайте, пока хеш на вашем сервере считается меньше секунды и вход не тормозит.
Какую длину пароля разрешать?
Не меньше 15 символов, если пароль единственный фактор входа, и не меньше 8 в многофакторной схеме. Максимум ставьте не ниже 64 символов, а смесь разных типов символов (цифры, заглавные буквы) не требуйте. С bcrypt учитывайте лимит 72 байта: 64 русские буквы в него не войдут.








