Соль (salt) - уникальная случайная строка, которая подмешивается к каждому паролю перед хешированием и хранится в базе рядом с готовым хешем. Перец (pepper) - общий секрет для всех паролей сразу, в базе он не хранится: живёт в KMS, Vault, HSM или переменной окружения и добавляется к паролю через HMAC перед вызовом Argon2id или bcrypt.
Соль обнуляет радужные таблицы и скрывает тот факт, что два пользователя выбрали одинаковый пароль. Перец лишает смысла украденный дамп базы: без секрета подбор по словарю и маскам не даёт результата даже на кластере GPU. Рабочая конфигурация на 2026 год выглядит так: Argon2id с m=47104 КиБ, t=1, p=1, 16 байт соли на каждый пароль, перец от 32 байт в менеджере секретов и версия перца в строке хеша.
Что такое соль и перец в хешировании паролей
Оба параметра меняют вход хеш-функции, но лежат в разных местах и закрывают разные угрозы. Соль видит каждый, кто получил доступ к таблице пользователей. Перец в базу не попадает: он остаётся в защищённом хранилище, поэтому дамп БД сам по себе бесполезен.
Аналогия для запоминания: соль - персональная приправа к каждой тарелке, своя и открытая, перец - общий секретный ингредиент, который держат на кухне под замком. Ни то, ни другое не заменяет сам рецепт, то есть современный алгоритм хеширования.
Соль (salt): уникальность для каждого пароля
Соль генерируют криптографически стойким генератором: secrets.token_bytes(16) в Python, random_bytes(16) в PHP, crypto/rand в Go. NIST SP 800-63B требует минимум 32 бита, практический стандарт - 16 байт (128 бит). Значение уникально для каждой записи, поэтому генерировать соль один раз при старте приложения или привязывать её к имени пользователя нельзя.
Два пользователя с паролем qwerty получат разные хеши, потому что соли разные. Секретом соль не считается: в строке формата PHC она лежит рядом с хешем, например $argon2id$v=19$m=47104,t=1,p=1$<salt>$<hash>. У bcrypt соль и хеш склеены в один блок base64: $2b$12$<salt+hash>.
Шестнадцати байт хватает с большим запасом. Для базы на 10 млн пользователей вероятность совпадения двух солей лежит в районе 10^-25, то есть на практике нулевая. Уменьшать длину ради экономии места бессмысленно: соль весит 16 байт, а атака на короткую соль дешевеет.
Перец (pepper): глобальный секрет вне базы данных
Перец - одна длинная строка на всё приложение, минимум 32 байта. Схема обработки: base64(HMAC-SHA-256(ключ=перец, сообщение=пароль)), результат передаётся в Argon2id или bcrypt. OWASP описывает такой приём как keyed hash и считает его корректным вариантом перца. Побочная выгода: HMAC всегда возвращает 32 байта, поэтому лимит bcrypt в 72 байта не срабатывает, а base64 убирает нулевые байты, на которых bcrypt обрывает строку.
Храните перец в менеджере секретов: HashiCorp Vault, облачный KMS, HSM. Переменная окружения допустима как компромисс для небольших проектов, но файл .env в репозитории перестаёт быть секретом в момент первого git push. Схемы управления ключами и автоматической ротации подробно разобраны в материале про шифрование данных при хранении и передаче: клиентские, серверные и гибридные модели.
Перец не отменяет соль и работает вместе с ней. HMAC считается до KDF, а соль генерирует сам алгоритм или передаётся ему явно в Argon2.
| Свойство | Соль (salt) | Перец (pepper) |
|---|---|---|
| Уникальность | своя для каждой записи | одна на всю систему |
| Где хранится | в БД рядом с хешем, внутри PHC-строки | KMS, Vault, HSM, переменная окружения |
| Секретность | открытые данные | секрет с доступом по сервисной роли |
| Длина | 16 байт (минимум 32 бита по NIST SP 800-63B) | 32 байта и больше |
| Что закрывает | радужные таблицы, одинаковые пароли | офлайн-подбор после утечки дампа |
| Ротация | меняется вместе с паролем | плановая, ленивая, с версией рядом с хешем |
Зачем нужна соль при хешировании паролей
Без соли хеш одного и того же пароля выглядит одинаково у всех пользователей и на всех серверах. Это открывает две атаки: применение заранее посчитанных таблиц и массовую компрометацию аккаунтов с одинаковыми паролями.
Защита от радужных таблиц
Радужная таблица - заранее посчитанное соответствие между паролями и хешами, где вместо полного перебора хранятся цепочки с функциями редукции. Для 10 млн паролей такая таблица занимает гигабайты, а с длинными цепочками - сотни гигабайт. Скачать её и найти нужный хеш дешевле, чем считать перебор самостоятельно.
Соль ломает эту схему. Таблица строится под конкретный вход, и при уникальной соли её нужно пересобирать для каждой записи: на базе из 1 млн пользователей атакующему понадобится 1 млн таблиц. С учётом того, что один вызов Argon2id с памятью 46 МиБ занимает десятки миллисекунд, пересчёт на каждую соль делает атаку экономически бессмысленной.
Предотвращение атак на одинаковые пароли
Утечка базы без соли показывает атакующему группы одинаковых хешей. Один подобранный пароль даёт доступ ко всем аккаунтам группы и подсказывает, какие пароли популярны в системе. С солью каждый хеш уникален, и связать пользователей между собой по дампу невозможно.
Соль не компенсирует слабый пароль. qwerty подберётся и с солью, просто теперь перебор считается отдельно для каждого пользователя, поэтому стоимость атаки растёт линейно по числу аккаунтов. Добавьте перец, и офлайн-подбор без секрета теряет смысл полностью.
Как реализовать соль и перец: примеры на Python, PHP и Go
Предхеш через HMAC считается одинаково во всех языках: base64(HMAC-SHA-256(перец, пароль)). Дальше работает KDF, а в базу уходит строка хеша плюс идентификатор версии перца, чтобы старые записи проверялись прежним секретом.
Python: bcrypt и Argon2id с перцем
import os, hmac, hashlib, base64
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
PEPPER = os.environ['PEPPER'].encode('utf-8')
ph = PasswordHasher(time_cost=1, memory_cost=47104, parallelism=1, hash_len=32, salt_len=16)
def peppered(password: str) -> str:
digest = hmac.new(PEPPER, password.encode('utf-8'), hashlib.sha256).digest()
return base64.b64encode(digest).decode('ascii')
def hash_password(password: str) -> str:
return ph.hash(peppered(password))
def verify_password(stored_hash: str, password: str) -> bool:
try:
return ph.verify(stored_hash, peppered(password))
except VerifyMismatchError:
return False
Библиотека argon2-cffi сама берёт соль из CSPRNG и укладывает её в PHC-строку вместе с параметрами, поэтому отдельная колонка под соль не нужна. Если в проекте уже используется bcrypt, берите cost 12 и не забывайте про предхеш: без него пароль длиннее 72 байт обрежется. Обёртки вида bcrypt_sha256 в passlib снимают ограничение по длине за счёт предварительного хеширования, но перец всё равно добавляйте своим HMAC перед вызовом библиотеки.
PHP: password_hash и sodium_crypto_pwhash
<?php
$pepper = getenv('PEPPER'); // 32+ байта из KMS или переменной окружения
function peppered(string $password, string $pepper): string {
$digest = hash_hmac('sha256', $password, $pepper, true);
return base64_encode($digest);
}
function hash_password(string $password, string $pepper): string {
return password_hash(peppered($password, $pepper), PASSWORD_ARGON2ID, [
'memory_cost' => 47104,
'time_cost' => 1,
'threads' => 1,
]);
}
function verify_password(string $stored, string $password, string $pepper): bool {
return password_verify(peppered($password, $pepper), $stored);
}
password_hash генерирует соль через CSPRNG и возвращает готовую строку вида $argon2id$v=19$m=47104,t=1,p=1$<salt>$<hash>, её и пишем в колонку. password_verify читает параметры из самой строки, поэтому повышение cost или смена памяти не ломает старые хеши. Функция sodium_crypto_pwhash даёт полный контроль: соль 16 байт из random_bytes, а opslimit и memlimit задаются под железо, стартовая точка - memlimit 64 МиБ и opslimit 2.
Go: bcrypt и argon2 с перцем
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"os"
"golang.org/x/crypto/bcrypt"
)
var pepper = []byte(os.Getenv("PEPPER"))
func peppered(password string) []byte {
mac := hmac.New(sha256.New, pepper)
mac.Write([]byte(password))
return []byte(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
}
func HashPassword(password string) ([]byte, error) {
return bcrypt.GenerateFromPassword(peppered(password), 12)
}
func VerifyPassword(hash []byte, password string) error {
return bcrypt.CompareHashAndPassword(hash, peppered(password))
}
bcrypt.GenerateFromPassword создаёт соль сам и записывает её в хеш, cost 12 на современном сервере даёт примерно 200-400 мс на проверку. Для Argon2 из golang.org/x/crypto/argon2 соль генерируется вручную из crypto/rand, а PHC-строку собираете сами:
salt := make([]byte, 16)
io.ReadFull(rand.Reader, salt)
key := argon2.IDKey(peppered(password), salt, 1, 47104, 1, 32)
stored := "$argon2id$v=19$m=47104,t=1,p=1$" +
base64.RawStdEncoding.EncodeToString(salt) + "$" +
base64.RawStdEncoding.EncodeToString(key)
В обоих случаях перец читается из защищённого хранилища, а не из константы в коде. Сравнение секретов и токенов делайте через constant-time функции: hmac.compare_digest в Python, hash_equals в PHP, subtle.ConstantTimeCompare в Go.
Типичные ошибки при работе с солью и перцем
Большинство инцидентов связано не с выбором алгоритма, а с тем, как обращаются с солью и перцем вокруг него. Три ошибки встречаются в аудитах чаще остальных.
Статическая соль: почему это опасно
Одна соль на всех, записанная в конфиг как mysalt, обнуляет защиту. Атакующий видит её в дампе базы, строит одну радужную таблицу под эту соль и вскрывает все пароли разом. Привязка соли к имени пользователя или к email лучше статической, но предсказуема: для служебных учёток вида admin и root таблицу можно посчитать заранее. Соль генерируется для каждой записи отдельно и никогда не переиспользуется, даже при смене пароля тем же пользователем.
Хранение перца в той же таблице
Колонка pepper в таблице users или файл рядом с дампом обнуляют весь смысл перца: утечка уносит и хеши, и секрет. Та же проблема с перцем в docker-compose.yml, в Ansible-плейбуке без vault, в CI-переменных с публичным доступом и в логах приложения при отладке. Перец держат в KMS или Vault, выдачу секрета приложению ограничивают сервисной ролью и журналируют, а доступ к самому хранилищу закрывают вторым фактором.
Отсутствие ротации перца
Перец живёт в системе годами, уходит вместе с уволившимся инженером, попадает в бэкап конфигов и в снимки виртуальных машин. Ротация строится без сброса паролей: рядом с хешем хранится pepper_id, при смене секрета добавляется новая версия, старые хеши проверяются прежним перцем, а при успешном входе пароль перехешируется с актуальной версией. Когда счётчик записей на старом секрете дошёл до нуля, версия выводится из обращения. Если перец скомпрометирован, ленивой ротации мало: атакующий уже может перебирать офлайн, поэтому нужна принудительная смена паролей пользователей.
К этому списку добавляются ошибки, которые ломают схему ещё до перца и соли:
- MD5, SHA-1 или одиночный SHA-256 с солью: GPU считает миллиарды вариантов в секунду, растяжения ключа нет.
- Соль из mt_rand(), time() или порядкового номера пользователя: значения предсказуемы.
- Слишком короткие параметры: соль 8 байт, перец 16 символов.
- Сравнение хешей оператором == вместо constant-time функции: утечка через замеры времени.
- Обрезка пароля до 72 байт перед bcrypt без предхеша: длинные парольные фразы теряют энтропию.
Актуальность подходов в 2026 году: рекомендации OWASP и NIST
Что говорят OWASP и NIST
OWASP Password Storage Cheat Sheet ставит Argon2id первым выбором и даёт минимальную конфигурацию m=47104 КиБ (46 МиБ), t=1, p=1, либо m=19456 КиБ (19 МиБ), t=2, p=1 для менее мощного железа. Для scrypt рекомендованы N=2^17, r=8, p=1, для bcrypt - cost не ниже 10, для PBKDF2-HMAC-SHA256 - 600 000 итераций. Перец OWASP советует хранить в HSM, а не в базе, и реализовывать его как keyed hash на HMAC, а не простой конкатенацией.
NIST SP 800-63B требует соль длиной не менее 32 бит и растяжение ключа при проверке запомненного секрета, а также рекомендует защищать верификаторы дополнительным секретом. MD5, SHA-1 и одиночный SHA-256 без растяжения для паролей не подходят: разница в скорости перебора между ними и Argon2id измеряется порядками.
Выбор алгоритма: bcrypt, Argon2id, scrypt
| Алгоритм | Память на проверку | Стойкость к GPU | Особенности | Когда брать |
|---|---|---|---|---|
| Argon2id | 46 МиБ (настраивается) | высокая | memory-hard, победитель PHC, параметры m/t/p | новый код, первый выбор |
| bcrypt | около 4 КиБ | средняя | лимит 72 байта, соль внутри хеша, проверен временем | существующие хранилища, совместимость |
| scrypt | 128 МиБ при N=2^17 | высокая | memory-hard, чувствителен к настройке памяти | альтернатива Argon2id в устоявшемся стеке |
| PBKDF2-HMAC-SHA256 | нет | низкая | только совместимость с требованиями FIPS | legacy, не для новых проектов |
Для нового кода берите Argon2id с параметрами из таблицы выше. Для действующего bcrypt-хранилища поднимите cost до 12 и добавьте перец, а переход на Argon2id делайте по плану ленивой миграции, чтобы не устраивать массовый сброс паролей.
Как внедрить соль и перец в существующую систему
Пошаговый план миграции
- Аудит. Соберите статистику по колонке хешей: SELECT left(password_hash, 7), count(*) FROM users GROUP BY 1. Так видно, какие алгоритмы и параметры реально лежат в базе.
- Целевая схема. Argon2id m=47104, t=1, p=1, соль 16 байт, перец 32 байта, версия перца в отдельной колонке pepper_id.
- Структура таблицы. Добавьте password_hash text, pepper_id smallint, updated_at timestamptz. Отдельная колонка под соль не нужна, если она внутри PHC-строки.
- Запись. Регистрация и смена пароля сразу пишут хеши по новой схеме с актуальным pepper_id.
- Проверка и перехеширование. При входе разбирайте префикс хеша, выбирайте секрет по pepper_id, проверяйте пароль и, если запись устарела, перехешируйте её. Открытый пароль доступен только в этот момент, другого окна для перехода на Argon2id нет.
- Секрет. Положите перец в Vault или KMS, выдавайте приложению по короткоживущему токену, включите аудит выдачи.
- Контроль. Считайте долю записей на старом алгоритме. Когда счётчик дошёл до нуля, старую ветку проверки удаляете из кода.
Пошаговые схемы шифрования и ротации ключей для этого сценария собраны в статье про практические схемы шифрования 2026 года: там есть чек-лист ротации и варианты хранения секретов вне репозитория.
Тестирование и мониторинг
Миграцию прогоняйте на копии боевой базы с реальным объёмом пользователей. Argon2id с памятью 46 МиБ на 50 одновременных входах потребует до 2,3 ГБ свободной памяти и 1-3 ядра при t=1, поэтому нагрузочный тест покажет, нужен ли отдельный пул для проверки паролей. Стенд с копией базы и managed-базой данных удобно поднять в облаке, например в Timeweb Cloud, где есть VDS и базы с шифрованием диска.
В мониторинг положите p95 времени проверки пароля и алерт на рост выше 500 мс, счётчик записей по pepper_id и число операций перехеширования. Проверьте откат: держите старый и новый перец одновременно и убедитесь, что вход работает на обеих версиях. Для ревью криптомодуля добавьте статический анализ, а разбор кода можно ускорить LLM-ассистентом через AiTunnel, который даёт единый API к GPT, Gemini и Claude с оплатой в рублях.
Соль и перец закрывают офлайн-подбор, но не перехват сессии и фишинг, поэтому схему стоит дополнить вторым фактором: готовые шаги собраны в руководстве по настройке 2FA в 2026 году.
Начните с одного изменения сегодня: сгенерируйте перец длиной 32 байта, положите его в Vault или KMS и добавьте HMAC-SHA-256 перед вызовом Argon2id. Следующий шаг - колонка pepper_id и ленивое перехеширование при входе, после которого старые быстрые хеши исчезнут из базы без единого письма в поддержку.