Схема таблицы пользователей для безопасного хранения паролей: поля, типы данных и индексы в PostgreSQL и MySQL | AdminWiki

Схема таблицы пользователей для безопасного хранения паролей: поля, типы данных и индексы в PostgreSQL и MySQL

15 сентября 2026 13 мин. чтения
Содержание статьи

В колонке с паролем храните не сам пароль, а его хеш, посчитанный медленным алгоритмом: 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$hash95-120 байтVARCHAR(255)
scrypt (PHC)$scrypt$ln=17,r=8,p=1$salt$hash110-160 байтVARCHAR(255)
PBKDF2pbkdf2_sha256$600000$salt$hash90-140 байтVARCHAR(255)
Бинарный bcrypt16 байт соли + 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.

Сравнение двух СУБД по ключевым решениям:

ЗадачаPostgreSQLMySQL 8
Текстовый хешVARCHAR(255) или TEXTVARCHAR(255)
Бинарный хешBYTEAVARBINARY(255)
Регистронезависимый emailCITEXT или индекс lower(email)collation utf8mb4_0900_ai_ci
Регистрозависимый usernameобычный индексCOLLATE utf8mb4_0900_as_cs
Имя алгоритмаколонка password_algogenerated 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 покажут, кто читал хеши. Методика проверки привилегий и соединений описана в гайде по аудиту безопасности баз данных.

Практические рекомендации и типичные ошибки

Чек-лист: порядок работ

  1. Выберите алгоритм: Argon2id для новых проектов, bcrypt там, где важна совместимость с существующими библиотеками.
  2. Задайте параметры: Argon2id m=19456 КиБ, t=2, p=1 как минимум; bcrypt cost=12.
  3. Определите тип колонки: VARCHAR(255) для PHC-строки, BYTEA или VARBINARY(255) для бинарного формата.
  4. Создайте таблицу и уникальные индексы на username и email по DDL из статьи.
  5. Хеширование и проверку держите в приложении: соль из CSPRNG, сравнение за постоянное время (password_verify, hmac.compare_digest).
  6. Добавьте ограничение числа попыток входа и задержку после неудач.
  7. Проверьте логи приложения и СУБД на утечку паролей.
  8. Включите шифрование бэкапов и проведите тестовое восстановление.
  9. Задокументируйте алгоритм, параметры и дату их пересмотра.

Дополнительный секрет (перец), который хранится в 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 и осмысленными значениями, в базе лежат открытые пароли. В этом случае перенос на хеши с солью становится первой задачей недели, а всё остальное из чек-листа выполняется сразу после неё.

Поделиться:
Сохранить гайд? В закладки браузера