В колонке с паролем храните не сам пароль, а его хеш, посчитанный медленным алгоритмом: Argon2id, bcrypt, scrypt или PBKDF2. Практические длины полей: VARCHAR(60) для bcrypt, VARCHAR(255) для Argon2id, scrypt и PBKDF2, BYTEA в PostgreSQL и VARBINARY(255) в MySQL для бинарного представления. Соль для каждой записи уникальна, а параметры алгоритма удобнее держать в той же строке, что и хеш, в формате PHC.
Открытый пароль в таблице users означает, что дамп базы, реплика, снимок диска или резервная копия на файловом сервере раскрывают учётные данные в читаемом виде. Люди повторяют пароли в рабочих и личных сервисах, поэтому одна утечка запускает цепочку взломов далеко за пределами вашего контура.
Схема работы: клиент присылает пароль по TLS, приложение считает хеш медленным алгоритмом с уникальной солью и пишет в базу только строку хеша. При входе приложение повторяет вычисление и сравнивает результат за постоянное время. База данных хранит значение и в проверке пароля не участвует.
Почему нельзя хранить пароли открыто и что использовать вместо них
Почему быстрые хеши не подходят для паролей
MD5 и SHA-256 проектировали для скорости: одна современная видеокарта считает порядка 10^10 хешей SHA-256 в секунду. При такой производительности пароль из восьми строчных латинских букв и цифр (62^8 = 2,18×10^14 комбинаций) перебирается за считаные часы на одной GPU, а на кластере из десятка карт - за минуты. У MD5 скорость ещё выше, а предвычисленные таблицы снимают вопрос перебора полностью.
bcrypt с cost=12 считает 150-400 хешей в секунду на одном ядре CPU и порядка 10^4 в секунду на GPU. Тот же словарь растягивается на столетия счёта. Argon2id добавляет к вычислительной сложности требовательность к памяти: 19-64 МиБ на один расчёт делают массовый параллельный перебор дорогим, потому что память GPU или специализированной платы заканчивается быстрее, чем вычислительные блоки.
Роль соли и параметров алгоритма
Соль - случайная строка от 16 байт, уникальная для каждой записи. Она обнуляет предвычисленные таблицы: без соли одинаковые пароли дают одинаковые хеши, и злоумышленник видит группы совпадений. С солью одно и то же значение «Qwerty123» превращается в две разные строки хешей у двух пользователей.
Параметры задают цену одного расчёта и лежат рядом с хешем в формате PHC:
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
Разбор строки: алгоритм argon2id, версия v=19, m=65536 КиБ памяти (64 МиБ), t=3 итерации, p=4 потока, далее соль и сам хеш. Любая библиотека с поддержкой PHC проверит пароль по этой строке без дополнительных данных.
Ориентиры на 2026 год: Argon2id с m=19456 КиБ, t=2, p=1 как минимум и m=65536, t=3, p=4 для новых проектов; bcrypt с cost не ниже 10, на практике 12; PBKDF2-HMAC-SHA256 с 600 000 итераций, если Argon2 использовать нельзя; scrypt с N=2^17, r=8, p=1. Как устроены соль и перец, где хранить секретный ключ и как выглядят готовые примеры на Python, PHP и Go, подробно разобрано в материале про соль и перец в хешировании паролей.
Слабый пароль не спасёт даже Argon2id: перебор идёт по спискам из миллионов реальных паролей и их вариаций, а не по всему пространству символов. Отсюда требования к минимальной длине, проверка введённого значения по базам скомпрометированных паролей и ограничение числа попыток входа на аккаунт и на IP.
Выбор типа и длины поля для хеша пароля
| Алгоритм | Пример формата | Длина строки | Рекомендуемое поле |
|---|---|---|---|
| bcrypt | $2b$12$... | ровно 60 байт | VARCHAR(60), лучше VARCHAR(255) |
| Argon2id (PHC) | $argon2id$v=19$m=...,t=...,p=...$salt$hash | 95-120 байт | VARCHAR(255) |
| scrypt (PHC) | $scrypt$ln=17,r=8,p=1$salt$hash | 110-160 байт | VARCHAR(255) |
| PBKDF2 | pbkdf2_sha256$600000$salt$hash | 90-140 байт | VARCHAR(255) |
| Бинарный bcrypt | 16 байт соли + 23 байта хеша | 39-40 байт | BYTEA / VARBINARY(64) |
VARCHAR vs BYTEA/VARBINARY: что выбрать
Текстовое хранение выигрывает в эксплуатации: строку видно в psql и mysql-клиенте, её легко скопировать между средами, передать в любую библиотеку и сверить при разборе инцидента. Алгоритм, параметры и соль закодированы внутри строки, поэтому схема остаётся простой: одна колонка, один тип. Плата - примерно треть лишних байт на base64 и зависимость сравнения от collation в MySQL.
Бинарное хранение компактнее: 32 байта вместо 44 для SHA-256-хеша и 40 вместо 60 для bcrypt. Сравнение идёт побайтово, без правил сортировки, поэтому исключены сюрпризы вида «A» = «a». Цена - нечитаемые дампы и конвертация при отладке.
Сжатие в PostgreSQL здесь ни при чём: механизм TOAST включается для значений около 2 КБ и больше, а 60-байтовый хеш остаётся в основной странице таблицы. Экономию даёт только сам бинарный формат, и она измеряется десятками байт на запись.
Длина поля для разных алгоритмов
bcrypt всегда выдаёт 60 символов ASCII, поэтому CHAR(60) в MySQL формально достаточен. Ровно эта точность и создаёт проблему: при переходе на Argon2id колонку придётся расширять через ALTER TABLE, а на таблице в миллионы строк это блокировка и окно обслуживания. VARCHAR(255) снимает вопрос на годы вперёд.
Длины PHC-строк не фиксированы: Argon2id с параметрами m=65536, t=3, p=4 даёт около 95-100 байт, а рост памяти до m=262144 и соли до 32 байт добавляют ещё десятки символов. scrypt записывает ln, r и p в текстовом виде, поэтому строка доходит до 150-160 байт. PBKDF2 в Django-совместимом виде с 600 000 итераций укладывается в 90-140 байт, но счётчик итераций со временем растёт вместе с длиной.
Практический выбор: VARCHAR(255) для текстового хранения, BYTEA или VARBINARY(255) для бинарного. Если база уже использует TEXT в PostgreSQL, менять его не нужно, ограничение длины на скорость не влияет.
Хранение соли и параметров алгоритма: отдельные поля или единая строка
Формат PHC и его преимущества
Единая строка самодостаточна: приложение читает алгоритм из префикса, применяет указанные параметры и сверяет результат. В одной колонке могут лежать хеши разных алгоритмов, что открывает плавную миграцию: новые записи получают Argon2id, старые продолжают работать как bcrypt, и никто не обязан менять пароль в один день.
bcrypt использует ту же идею в своём формате: $2b$12$ + 22 символа base64-соли + 31 символ хеша. Библиотеки bcrypt, argon2-cffi, passlib и встроенные функции PHP, Python и Go распознают такие строки автоматически, поэтому выносить параметры в отдельные колонки в большинстве случаев незачем.
Когда стоит выносить соль и параметры в отдельные поля
Раздельная схема выглядит так: password_hash VARCHAR(255), salt BYTEA NOT NULL, algorithm VARCHAR(20), cost SMALLINT, memory_kib INTEGER, parallelism SMALLINT, iterations SMALLINT. Она оправдана, когда нужен массовый аудит или централизованное обновление параметров:
SELECT id, algorithm, cost FROM users WHERE algorithm = 'bcrypt' AND cost < 12;
Такой запрос мгновенно находит аккаунты, которым нужен новый хеш, а параметры дальше поднимаются одним UPDATE. Минус схемы - целостность: алгоритм, соль и хеш должны совпадать, иначе проверка пароля молча сломается. Защита от рассинхронизации - NOT NULL, CHECK-ограничения и запись всех полей в одной транзакции.
Компромисс для PostgreSQL 12+ и MySQL 8: держите PHC-строку в password_hash и добавьте вычисляемое поле с именем алгоритма. В MySQL это делается так:
password_algo VARCHAR(20)
GENERATED ALWAYS AS (SUBSTRING_INDEX(password_hash, '$', 2)) STORED
Колонка обновляется сама, индексируется и не может разойтись с хешем. В PostgreSQL аналог - GENERATED ALWAYS AS ... STORED, доступный с версии 12.
Готовые DDL-примеры для PostgreSQL и MySQL
DDL для PostgreSQL
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
password_algo VARCHAR(20) NOT NULL DEFAULT 'argon2id',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX users_username_lower_key ON users (lower(username));
CREATE UNIQUE INDEX users_email_lower_key ON users (lower(email));
COMMENT ON COLUMN users.password_hash
IS 'PHC-строка: алгоритм, параметры, соль и хеш';
Индексы по lower(username) и lower(email) дают регистронезависимый вход без расширения CITEXT и без приведения типов в запросах. Если CITEXT уже используется в проекте, пишите email CITEXT NOT NULL и добавляйте обычный UNIQUE: логика та же, но расширение придётся не забыть при восстановлении дампа.
Вариант с бинарным хешем и отдельной солью:
password_hash BYTEA NOT NULL,
salt BYTEA NOT NULL,
У BYTEA длина не задаётся, поэтому схема одинаково примет 32-байтовый вывод SHA-256 и 64-байтовый результат Argon2. Права сузьте до минимума: UPDATE на password_hash нужен единственной роли сервиса аутентификации, аналитикам выдавайте доступ к конкретным колонкам через GRANT SELECT (id, username, email) ON users.
DDL для MySQL
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(50) CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_as_cs NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
password_algo VARCHAR(20)
GENERATED ALWAYS AS (SUBSTRING_INDEX(password_hash, '$', 2)) STORED,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_users_username (username),
UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;
Разбор решений: email получает регистронезависимую collation, username - регистрозависимую через utf8mb4_0900_as_cs, чтобы «Ivan» и «ivan» остались разными учётными записями. Уникальный индекс по email из 255 символов utf8mb4 занимает 1020 байт и укладывается в лимит 3072 байта формата строки DYNAMIC, поэтому префиксный индекс не нужен. Для бинарного варианта замените тип на password_hash VARBINARY(255) NOT NULL.
Сравнение двух СУБД по ключевым решениям:
| Задача | PostgreSQL | MySQL 8 |
|---|---|---|
| Текстовый хеш | VARCHAR(255) или TEXT | VARCHAR(255) |
| Бинарный хеш | BYTEA | VARBINARY(255) |
| Регистронезависимый email | CITEXT или индекс lower(email) | collation utf8mb4_0900_ai_ci |
| Регистрозависимый username | обычный индекс | COLLATE utf8mb4_0900_as_cs |
| Имя алгоритма | колонка password_algo | generated column |
| Проверка пароля | в приложении | в приложении |
Индексы для таблицы пользователей: что действительно ускоряет аутентификацию
Вход в систему состоит из двух операций: поиск строки по username или email и проверка хеша. Индекс ускоряет только поиск. Проверку выполняет приложение на CPU, и никакое B-дерево её не ускорит, потому что сравнение идёт с солью и параметрами конкретной записи.
Оптимальный набор индексов
- PRIMARY KEY по id: основа для ссылок из таблиц сессий, токенов и журнала аудита.
- UNIQUE по username: поиск при входе и защита от дублей.
- UNIQUE по email: вход по почте, восстановление пароля, уведомления.
- Индекс по (is_active, created_at), если админка регулярно фильтрует активных пользователей и сортирует их по дате регистрации.
- Частичный индекс по заблокированным аккаунтам, если таких записей мало и выборка по ним идёт отдельно.
Индекс на password_hash не нужен: по хешу никто не ищет строку, а уникальность теряет смысл, потому что один и тот же пароль даёт разные хеши из-за соли. Такой индекс только замедляет INSERT и UPDATE.
Запрос аутентификации и его план в PostgreSQL:
EXPLAIN (ANALYZE, BUFFERS) SELECT id, password_hash FROM users WHERE username = 'alice'; Index Only Scan using users_username_key on users (cost=0.28..8.30 rows=1 width=40) Index Cond: (username = 'alice'::text) Heap Fetches: 0
План читается так: строку нашли по индексу и к таблице не обращались, потому что оба нужных столбца лежат в самом индексе. Для такого плана создайте покрывающий индекс: CREATE UNIQUE INDEX users_username_key ON users (username) INCLUDE (password_hash). В MySQL поиск по уникальному ключу даёт type: const и rows: 1 в EXPLAIN. Приёмы работы с индексами и медленными выборками на больших объёмах собраны в руководстве про устранение медленных запросов.
Влияние индексов на скорость вставки и обновления
Каждый уникальный индекс добавляет проверку дублей и запись в B-дерево. На потоке регистраций в 100 000 строк два уникальных ключа вместо одного увеличивают время загрузки заметно, и на практике разница измеряется десятками процентов. Массовый импорт ускоряют так: отключают необязательные индексы, грузят данные пакетами, затем пересобирают индексы заново. В PostgreSQL для заливки берут COPY, в MySQL - LOAD DATA с батчами и увеличенным innodb_buffer_pool_size.
Смена пароля индексы не затрагивает: колонки username и email остаются прежними, поэтому обновление password_hash дешёвое.
Как предотвратить утечки паролей через логи и резервные копии
Хеш в базе снижает ущерб от утечки, но открытый пароль, попавший в лог или дамп, остаётся в них навсегда. Логи и бэкапы живут дольше, чем инцидент, и часто лежат в местах с менее строгим доступом, чем сама база.
Настройка логирования без утечек
- В приложении: белый список полей при логировании. Объект пользователя никогда не пишется целиком, сериализаторы исключают password, password_hash и токены. Сообщение об ошибке аутентификации не содержит введённый пароль ни в тексте, ни в теле HTTP-ответа.
- PostgreSQL: log_statement = 'none' в продакшене. Для поиска медленных запросов включайте log_min_duration_statement, но проверьте, что значения параметров не пишутся в лог: log_parameter_max_length = 0 (версия 13 и новее) скрывает их.
- MySQL: general_log держите выключенным, slow_query_log включайте точечно. Binlog содержит те же данные, поэтому доступ к нему и шифрование (binlog_encryption = ON, версия 8.0.14+) настраиваются так же строго, как доступ к таблицам.
- PostgreSQL пишет данные в WAL, и он попадает в базовые бэкапы. Права на каталог с WAL и на архивы того же порядка, что и на саму базу.
- Раз в квартал прогоняйте логи приложения и СУБД по поиску слова "password": совпадение с реальным значением - повод для разбора. Готовые конфигурации аудита и логирования для MySQL собраны в руководстве по безопасности MySQL.
Защита резервных копий
- Шифруйте дампы на стороне источника: gpg --encrypt, age или openssl enc -aes-256-cbc -pbkdf2 -iter 100000. Ключ храните вне каталога с бэкапами.
- Роль для бэкапов получает только чтение: CREATE ROLE backup_ro LOGIN; GRANT SELECT ON users TO backup_ro; Права на запись, DDL и доступ к другим базам такой учётной записи не нужны.
- Файлы бэкапов: chmod 600, отдельный пользователь, каталог вне webroot, никаких общих сетевых папок с анонимным доступом.
- Облачное хранение: шифрование at rest и in transit, отдельный бакет, версионирование и политика жизненного цикла. Если база размещена в облаке, выбирайте провайдера с управляемыми бэкапами и шифрованием дисков, например Timeweb Cloud.
- Раз в квартал восстанавливайте дамп на тестовом стенде и проверяйте, что хеши на месте, а вход в приложение проходит. Непроверенный бэкап не считается бэкапом.
- Аудит доступа к таблице users: расширение pgaudit в PostgreSQL и audit-плагин в MySQL покажут, кто читал хеши. Методика проверки привилегий и соединений описана в гайде по аудиту безопасности баз данных.
Практические рекомендации и типичные ошибки
Чек-лист: порядок работ
- Выберите алгоритм: Argon2id для новых проектов, bcrypt там, где важна совместимость с существующими библиотеками.
- Задайте параметры: Argon2id m=19456 КиБ, t=2, p=1 как минимум; bcrypt cost=12.
- Определите тип колонки: VARCHAR(255) для PHC-строки, BYTEA или VARBINARY(255) для бинарного формата.
- Создайте таблицу и уникальные индексы на username и email по DDL из статьи.
- Хеширование и проверку держите в приложении: соль из CSPRNG, сравнение за постоянное время (password_verify, hmac.compare_digest).
- Добавьте ограничение числа попыток входа и задержку после неудач.
- Проверьте логи приложения и СУБД на утечку паролей.
- Включите шифрование бэкапов и проведите тестовое восстановление.
- Задокументируйте алгоритм, параметры и дату их пересмотра.
Дополнительный секрет (перец), который хранится в KMS или Vault и не попадает в дамп базы, защищает хеши от офлайн-перебора после утечки. Проектные решения по схеме определяют сложность бэкапов, мониторинга и роста таблицы на годы вперёд, что разобрано в статье о влиянии проектирования базы на администрирование.
Миграция на новый алгоритм без простоя
Пересчитать хеш из хеша нельзя: для нового алгоритма нужен открытый пароль. Поэтому миграция идёт через перехеширование при входе. Приложение смотрит на префикс строки в password_hash, и если алгоритм или параметры устарели, считает новый хеш из только что проверенного пароля и обновляет запись.
Порядок: включаете поддержку нового алгоритма для новых регистраций, старые хеши проверяете по-прежнему, при каждом успешном входе обновляете строку. Через месяц смотрите остаток по алгоритмам:
SELECT password_algo, count(*) FROM users GROUP BY password_algo ORDER BY count(*) DESC;
Тем, кто не входил полгода, отправляйте письмо со сбросом пароля. Второй вариант - держать password_hash_new рядом со старым полем и удалить старое после того, как новая колонка заполнится. Оба подхода не требуют окна обслуживания, кроме короткого ALTER TABLE для добавления колонки.
Типичные ошибки
- Колонка password с открытым текстом, оставшаяся от тестового стенда или импорта из CSV.
- MD5, SHA-1 и SHA-256 без соли, выбранные «на скорую руку» для внутреннего сервиса.
- Одна общая соль на всю таблицу или соль, полученная rand() вместо CSPRNG.
- Поле VARCHAR(40) или CHAR(32), в которое bcrypt-хеш не влезает, из-за чего обрезанная строка молча ломает вход.
- Индекс на password_hash, который замедляет запись и не помогает поиску.
- Логирование полного SQL-запроса с подставленным паролем при отладке формы входа.
- Резервные копии без шифрования на общем файловом ресурсе.
Начните с инвентаризации: SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'users'; Если среди колонок есть password или pwd с типом VARCHAR и осмысленными значениями, в базе лежат открытые пароли. В этом случае перенос на хеши с солью становится первой задачей недели, а всё остальное из чек-листа выполняется сразу после неё.