Хранение паролей в MySQL и MariaDB: встроенные функции и лучшие практики | AdminWiki

Хранение паролей в MySQL и MariaDB: встроенные функции и лучшие практики

15 сентября 2026 13 мин. чтения

Встроенные функции MySQL и MariaDB не годятся для хранения паролей пользователей приложения. PASSWORD() удалена в MySQL 8.0, SHA2() выдаёт одинаковый хеш для одинаковых строк и уязвима к радужным таблицам, MD5 и SHA-1 криптографически сломаны. Рабочая схема выглядит иначе: пароль хешируется в приложении медленным алгоритмом с солью (Argon2id, bcrypt, PBKDF2), а в таблице лежит только строка хеша вместе с идентификатором алгоритма.

Пароли учётных записей СУБД и пароли пользователей приложения живут в разных местах и защищаются по-разному. Хеши учётных записей MySQL и MariaDB хранятся во внутренней таблице mysql.user, ими управляют через CREATE USER, ALTER USER и плагины аутентификации. Пароли ваших пользователей лежат в вашей таблице users, и СУБД здесь только хранилище готовой строки.

Почему хранение паролей в MySQL и MariaDB требует особого подхода

Если пароль пользователя лежит в базе в открытом виде или под слабым хешем, каждая из четырёх точек отказа превращается в массовую утечку:

  • дамп базы, полученный через SQL-инъекцию или ошибку в правах доступа;
  • резервная копия на диске, в объектном хранилище или на ленте;
  • администратор БД или подрядчик с доступом к таблицам, читающий пароли напрямую;
  • реплика или промежуточная среда (dev, stage), куда скопировали боевые данные.

В отчетах Verizon Data Breach Investigations Report из года в год самой частой причиной компрометации остаётся использование украденных учётных данных. Это делает хранилище паролей приоритетной целью: получив дамп с таблицей users, атакующий не ломает периметр, а подбирает пароли локально, где нет ни блокировок аккаунта, ни логирования попыток.

Хеширование закрывает часть рисков, но не решает задачу целиком. Утечка дампа с bcrypt-хешами всё ещё даёт возможность офлайн-подбора слабых паролей. Защита строится слоями: медленный алгоритм с уникальной солью, минимальные привилегии у учётной записи приложения, шифрование резервных копий, контроль соединений. Разберём каждый слой на конкретных командах.

Встроенные функции MySQL и MariaDB для работы с паролями

В старых скриптах и статьях встречаются PASSWORD(), OLD_PASSWORD(), SHA2(), SHA1(), MD5(), ENCRYPT(), ENCODE() и DECODE(). Ни одна из них не предназначена для хранения паролей пользователей приложения. Ниже их статус и реальная область применения.

ФункцияСтатусДля чего годится
PASSWORD()Удалена в MySQL 8.0, доступна в MariaDBВнутренний хеш формата mysql_native_password
OLD_PASSWORD()Удалена в MySQL 5.7.5Совместимость с очень старыми инсталляциями до 4.1
SHA2(str, 256)Доступна в обеих СУБДКонтрольные суммы, индексы, не пароли
SHA1(), MD5()Доступны, криптостойкость утраченаПроверка целостности файлов и строк
ENCRYPT()Удалена в MySQL 8.0.7Unix crypt, исторические задачи
ENCODE()/DECODE()Удалены в MySQL 5.7Устаревшее обратимое шифрование

PASSWORD(): история и причины отказа

До MySQL 5.7 функция PASSWORD() возвращала значение, которое СУБД использовала для учётных записей. Формат менялся дважды. Старый, до версии 4.1, давал 16 hex-символов (mysql_old_password) и хранился в столбце Password таблицы mysql.user. Новый, с версии 4.1, даёт 41 символ: звёздочку и SHA-1 от SHA-1 пароля в верхнем регистре. Это формат mysql_native_password, который сейчас возвращает PASSWORD() в MariaDB.

Слабые места формата: одинарный SHA-1 без соли и без растяжения ключа. Два пользователя с одинаковым паролем получают одинаковый хеш, а радужные таблицы (rainbow tables) для mysql_native_password собираются за часы на обычной рабочей машине.

С MySQL 8.0 функция PASSWORD() удалена, вместе с ней убрали плагин mysql_old_password. По умолчанию сервер использует caching_sha2_password: хранит SHA-256 от пароля с солью, применяет итерации и требует защищённый канал или RSA-обмен для передачи пароля при первом подключении.

MariaDB сохраняет PASSWORD() для совместимости, но помечает как небезопасную. Плагин аутентификации там задаётся для каждой учётной записи отдельно в столбце mysql.user (например, unix_socket или mysql_native_password), а пароль приложения в эту функцию попадать не должен.

Проверьте конфигурацию до любых изменений:

SELECT VERSION();
SELECT user, host, plugin FROM mysql.user;
SHOW VARIABLES LIKE '%authentication%';

В MySQL 8.0 список активных плагинов виден через SHOW PLUGINS, а общая политика задаётся параметром authentication_policy в my.cnf. В MariaDB тот же результат вы получите просмотром столбца plugin по каждому пользователю.

SHA2() и MD5(): почему хеш без соли не защищает

SHA2() доступна и в MySQL, и в MariaDB. Проблема не в самом алгоритме, а в способе применения: функция детерминирована, один и тот же вход всегда даёт один и тот же выход.

SELECT SHA2('qwerty', 256);
SELECT SHA2('qwerty', 256);

Оба вызова вернут 64 hex-символа, идентичные до последнего байта. Зная только хеш, атакующий может:

  • прогнать по словарю миллионы паролей, посчитать SHA-256 и сравнить с хешами из дампа, не обращаясь к серверу;
  • подставить заранее собранную радужную таблицу и восстановить популярные пароли мгновенно;
  • по одинаковым хешам у разных строк определить, что пользователи выбрали один и тот же пароль, и атаковать их как группу.

Соль убирает все три сценария: к паролю добавляется случайная уникальная строка, и одинаковые пароли дают разные хеши. Вписать уникальную соль в одну SQL-функцию не получится: СУБД не генерирует соль на строку и не выполняет растяжение ключа (key stretching), которое делает подбор дорогим.

MD5 и SHA-1 отдельно: MD5 ломается подбором коллизий за минуты на бытовом железе, для SHA-1 практические коллизии публично показаны с 2017 года. Ни один из них не подходит даже как часть схемы проверки пароля.

Итог по разделу простой: SHA2() держите для контрольных сумм и индексов, а для паролей используйте медленный KDF с солью и настраиваемой стоимостью. Такой KDF живёт в приложении, а не в СУБД.

Почему хеширование на стороне приложения - правильный выбор

Приложение даёт то, чего не могут функции СУБД: уникальную случайную соль на каждый пароль, медленный алгоритм с настраиваемой стоимостью, версию алгоритма внутри строки хеша и возможность тихо перехешировать пароль при следующем входе.

Что выбирать:

  • Argon2id - первый выбор, победитель Password Hashing Competition. OWASP рекомендует параметры m=19456 КиБ (около 19 МиБ), t=2, p=1 как базовую конфигурацию;
  • bcrypt - рабочий вариант, если Argon2id недоступен. Стоимость не ниже 10, практически 12. Алгоритм обрезает пароль после 72 байт, это важно для длинных парольных фраз;
  • PBKDF2-HMAC-SHA256 - когда нужна встроенная в платформу реализация. OWASP указывает 600 000 итераций как ориентир.

Использовать нельзя одиночные MD5, SHA-1, SHA-256 и самодельные схемы вида sha256(salt + password) с фиксированной солью. В PHP есть password_hash() и password_verify(), в Python - bcrypt, argon2-cffi, passlib. Логика такая: приложение считает хеш, СУБД получает готовую строку и возвращает её при следующем входе.

Структура таблицы должна вмещать самый длинный хеш, который может появиться в будущем. Берите VARCHAR(255) с кодировкой utf8mb4 или VARBINARY(255).

CREATE TABLE users (
  id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  login         VARCHAR(64) NOT NULL,
  password_hash VARCHAR(255) CHARACTER SET ascii NOT NULL,
  hash_algo     VARCHAR(32) NOT NULL DEFAULT 'argon2id',
  updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uq_login (login)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Длина важнее, чем кажется. bcrypt выдаёт 60 символов, Argon2id в кодировке PHC - около 95-100 с учётом параметров, а смена алгоритма может дать больше. Столбец на 60 символов однажды срежет хеш, и вход перестанет работать у части пользователей.

Пример на PHP: password_hash() и password_verify() с MySQL/MariaDB

Подключение через PDO с отключённой эмуляцией подготовленных выражений: параметры уходят на сервер отдельно от текста запроса, и SQL-инъекция через логин или пароль не проходит.

$pdo = new PDO('mysql:host=127.0.0.1;dbname=appdb;charset=utf8mb4', $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES   => false,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare('INSERT INTO users (login, password_hash, hash_algo) VALUES (?, ?, ?)');
$stmt->execute([$login, $hash, 'argon2id']);

Проверка при входе с автоматическим перехешированием, если параметры устарели:

$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE login = ?');
$stmt->execute([$login]);
$row = $stmt->fetch();

if ($row && password_verify($password, $row['password_hash'])) {
    if (password_needs_rehash($row['password_hash'], PASSWORD_ARGON2ID)) {
        $newHash = password_hash($password, PASSWORD_ARGON2ID);
        $up = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
        $up->execute([$newHash, $row['id']]);
    }
}

Константа PASSWORD_DEFAULT сегодня указывает на bcrypt, а в будущих версиях PHP может указывать на Argon2id. Поэтому длина столбца 255 символов и вызов password_needs_rehash() при каждом входе обязательны. Кодировку соединения задавайте как utf8mb4, иначе кириллические логины будут обрезаться на границе символа.

Пример на Python: bcrypt и mysql.connector

pip install bcrypt mysql-connector-python
import bcrypt
import mysql.connector

conn = mysql.connector.connect(
    host='127.0.0.1', database='appdb',
    user='app', password='...', charset='utf8mb4',
)
cur = conn.cursor()

hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12))
cur.execute(
    'INSERT INTO users (login, password_hash, hash_algo) VALUES (%s, %s, %s)',
    (login, hashed.decode('ascii'), 'bcrypt'),
)
conn.commit()

Проверка:

cur.execute('SELECT id, password_hash FROM users WHERE login = %s', (login,))
row = cur.fetchone()

if row and bcrypt.checkpw(password.encode('utf-8'), row[1].encode('ascii')):
    pass  # вход разрешён

Плейсхолдеры в mysql.connector обозначаются через %s, и значения обязательно передаются вторым аргументом execute(). Склейка строки вида f"WHERE login = '{login}'" даёт классическую SQL-инъекцию вроде ' OR '1'='1.

Если нужен Argon2id, ставьте argon2-cffi: PasswordHasher() сам генерирует соль и упаковывает параметры в строку вида $argon2id$v=19$m=19456,t=2,p=1$.... Хранить её можно как есть, при проверке параметры разбираются автоматически.

Общие правила для обоих языков: не пишите пароль в логи и в текст ошибок, используйте TLS для соединения с базой (REQUIRE SSL), держите строку подключения в переменных окружения или в менеджере секретов.

Настройка прав доступа в MySQL и MariaDB для защиты учётных данных

Приложение не должно подключаться под root и не должно видеть служебные схемы. Принцип наименьших привилегий на практике: отдельный пользователь под каждое приложение, права только на нужные объекты, привязка к подсети, обязательный TLS.

CREATE USER 'app'@'10.0.0.%' IDENTIFIED BY 'пароль-из-менеджера-секретов';
GRANT SELECT, INSERT, UPDATE ON appdb.users TO 'app'@'10.0.0.%';
ALTER USER 'app'@'10.0.0.%' REQUIRE SSL;
SHOW GRANTS FOR 'app'@'10.0.0.%';

Свежесозданный пользователь не имеет прав на данные, только USAGE. Проверяйте это командой SHOW GRANTS: в выводе не должно быть строк GRANT ALL ON *.* и любых упоминаний mysql.*. Учётная запись с правом чтения mysql.user видит хеши всех пользователей СУБД.

SHOW GRANTS FOR 'app'@'10.0.0.%';
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app'@'10.0.0.%';

В MySQL 8.0 и MariaDB права удобнее выдавать через роли: набор привилегий описывается один раз и назначается нескольким приложениям и стендам.

CREATE ROLE 'app_auth';
GRANT SELECT (id, login, password_hash), UPDATE (password_hash) ON appdb.users TO 'app_auth';
GRANT 'app_auth' TO 'app'@'10.0.0.%';
SET DEFAULT ROLE 'app_auth' TO 'app'@'10.0.0.%';

Корневой доступ по сети закройте отдельно: убедитесь, что нет записей 'root'@'%', а bind-address сервера смотрит во внутренний интерфейс.

SELECT user, host FROM mysql.user WHERE user = 'root';

Разграничение доступа к таблице с паролями

Хеши стоит держать в отдельной таблице или отдельной схеме, чтобы остальной код не мог случайно вытащить их общим SELECT *. Три рабочих варианта изоляции:

  • отдельная таблица credentials с правами только у сервиса аутентификации;
  • отдельная схема authdb с собственным пользователем;
  • хранимая процедура, которая возвращает хеш по логину, при этом прямого SELECT у приложения нет.
CREATE PROCEDURE authdb.get_password_hash(IN p_login VARCHAR(64))
BEGIN
  SELECT password_hash, hash_algo FROM users WHERE login = p_login LIMIT 1;
END;

GRANT EXECUTE ON PROCEDURE authdb.get_password_hash TO 'app'@'10.0.0.%';

При таком подходе приложение получает хеш, проверяет пароль у себя и не имеет прав на чтение таблицы целиком. Дополнительно можно писать каждое обращение к процедуре в таблицу аудита и настроить алерт на аномальное число вызовов.

Общая рамка по аутентификации, шифрованию соединений и аудиту действий пользователей разобрана в полном руководстве по безопасности MySQL. Регулярную проверку выданных привилегий удобно проводить по чек-листу из статьи про аудит безопасности баз данных.

Шифрование резервных копий и защита от утечек

Дамп, снятый без шифрования, обесценивает меры на уровне СУБД: файл можно скопировать, отправить в облако или найти на диске старого сервера. Порядок такой: дамп, сжатие, шифрование, права 600, ключ отдельно от данных.

mysqldump --single-transaction --defaults-extra-file=/etc/mysql/backup.cnf appdb \
  | gzip -9 \
  | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt \
  -out /backup/appdb-$(date +%F).sql.gz.enc

Восстановление:

openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
  -in /backup/appdb-2026-09-15.sql.gz.enc | gunzip | mysql appdb

Асимметричный вариант через GPG: приватный ключ расшифровки хранится только на изолированном хосте, а на сервере приложения лежит публичный ключ. Компрометация продового сервера тогда не даёт расшифровать архив.

mysqldump --single-transaction appdb \
  | gzip -9 \
  | gpg --encrypt --recipient backup@example.com \
    --output /backup/appdb-$(date +%F).sql.gz.gpg

Практические детали, которые чаще всего забывают:

  • umask 077 в скрипте бэкапа и chmod 600 на готовый файл;
  • пароль БД не в командной строке: аргумент -pSecret виден в ps и остаётся в истории shell. Используйте --defaults-extra-file с файлом, у которого права 600;
  • ключ шифрования хранится отдельно от архива, в менеджере секретов или на носителе, недоступном с продового сервера;
  • объектное хранилище принимает только уже зашифрованные файлы, серверное шифрование провайдера это второй слой, а не первый;
  • проверка восстановления на тестовом стенде после каждой правки скрипта, иначе проблему с шифром обнаружат только в момент аварии.

Подходы к шифрованию на стороне клиента, сервера и в гибридных схемах, включая управление ключами, разобраны в материале про шифрование данных при передаче и хранении. Если база и бэкапы размещены в облаке, например в Timeweb Cloud, включайте шифрование до загрузки дампа в хранилище и держите ключ вне аккаунта облака.

Типичные ошибки при хранении паролей в MySQL и MariaDB

Ниже сведены ошибки, которые встречаются в аудитах чаще всего, вместе с последствиями и способом исправления.

ОшибкаПоследствиеКак исправить
Пароли в открытом виде в столбце VARCHARМгновенный доступ ко всем учётным данным из дампаХранить только хеш Argon2id или bcrypt, старые значения перехешировать при входе
SHA2(пароль, 256) без солиОдинаковые хеши, подбор по радужным таблицамПеренести хеширование в приложение с уникальной солью
PASSWORD() и OLD_PASSWORD() для паролей пользователейСлабый алгоритм формата mysql_native_password, функция удалена в MySQL 8.0Сменить алгоритм, проверить плагин аутентификации учётных записей СУБД
Пароль БД в командной строке скриптаУтечка через ps, историю shell и системы сбора логов--defaults-extra-file с правами 600 или переменные окружения
Пароль или хеш в логах приложенияУтечка в централизованное хранилище логов с широким доступомЛогировать факт входа без значений, выключить general_log на проде
Приложение работает под root или читает mysql.*Полный контроль над СУБД, включая хеши учётных записейОтдельный пользователь, минимальные GRANT, REQUIRE SSL
Незашифрованные бэкапыКопия всей базы на диске, ленте или в облакеopenssl или gpg, права 600, ключ отдельно
Один пароль БД на всех стендахИнцидент на dev открывает продРазные секреты на среду, ротация, менеджер секретов

Чек-лист для самопроверки на конкретной инсталляции:

  1. SELECT login, password_hash FROM users LIMIT 5; хеши начинаются с $argon2id$, $2y$ или $2b$, и никак иначе.
  2. SHOW GRANTS FOR 'app'@'...'; нет ALL ON *.* и нет mysql.*.
  3. SELECT user, host, plugin FROM mysql.user; нет 'root'@'%', пароли СУБД не старше политики ротации.
  4. general_log выключен, slow_query_log не содержит текстов паролей.
  5. Бэкап расшифровывается только с отдельным ключом, восстановление проверено на тестовом стенде.

Расширенный список проверок конфигурации и анализ логов на предмет аномалий приведены в руководстве по аудиту безопасности баз данных 2026.

Миграция на безопасное хеширование без простоя

Массовый сброс паролей обходится дороже, чем ленивая миграция. Схема такая: старый хеш остаётся на месте, новый пишется в отдельный столбец после первой успешной проверки пароля.

ALTER TABLE users
  ADD COLUMN password_hash_v2 VARCHAR(255) CHARACTER SET ascii NULL AFTER password_hash;

При входе проверяются оба формата, приоритет у нового:

$stmt = $pdo->prepare('SELECT id, password_hash, password_hash_v2 FROM users WHERE login = ?');
$stmt->execute([$login]);
$row = $stmt->fetch();
$ok = false;

if (!empty($row['password_hash_v2']) && password_verify($password, $row['password_hash_v2'])) {
    $ok = true;
} elseif (hash_equals($row['password_hash'], sha1($password))) {  // старый формат
    $ok = true;
    $up = $pdo->prepare('UPDATE users SET password_hash_v2 = ? WHERE id = ?');
    $up->execute([password_hash($password, PASSWORD_ARGON2ID), $row['id']]);
}

Порядок работ на боевой базе:

  1. Снять дамп и восстановить его на копии стенда, прогнать миграцию там.
  2. Добавить столбец и индекс, если планируются пакетные обновления. ALTER TABLE на большой таблице в MySQL 8.0 и MariaDB выполняется онлайн, но требует места на диске под перестройку.
  3. Задеплоить код с двойной проверкой. Вход продолжает работать со старым хешем, при первом успешном логине появляется новый.
  4. Следить за долей перехешированных строк: SELECT COUNT(*) FROM users WHERE password_hash_v2 IS NULL.
  5. Через один-два месяца отправить оставшимся пользователям ссылку на смену пароля, переименовать старый столбец в password_hash_legacy и отозвать право на его чтение у приложения.

Тонкость: hash_equals() вместо == защищает сравнение строк от timing-атак, а параметры Argon2id фиксируйте в конфиге приложения, иначе при смене значений по умолчанию придётся перехешировать всё заново. Откат делается просто: пока старый столбец существует, предыдущий релиз кода возвращается в любой момент.

После миграции остаётся рутина: раз в квартал сверять вывод SHOW GRANTS, отслеживать записи с устаревшим префиксом хеша и проверять, что бэкапы расшифровываются тем же ключом, который вы уже тестировали при восстановлении.

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