Встроенные функции 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.7 | Unix 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 открывает прод | Разные секреты на среду, ротация, менеджер секретов |
Чек-лист для самопроверки на конкретной инсталляции:
- SELECT login, password_hash FROM users LIMIT 5; хеши начинаются с $argon2id$, $2y$ или $2b$, и никак иначе.
- SHOW GRANTS FOR 'app'@'...'; нет ALL ON *.* и нет mysql.*.
- SELECT user, host, plugin FROM mysql.user; нет 'root'@'%', пароли СУБД не старше политики ротации.
- general_log выключен, slow_query_log не содержит текстов паролей.
- Бэкап расшифровывается только с отдельным ключом, восстановление проверено на тестовом стенде.
Расширенный список проверок конфигурации и анализ логов на предмет аномалий приведены в руководстве по аудиту безопасности баз данных 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']]);
}
Порядок работ на боевой базе:
- Снять дамп и восстановить его на копии стенда, прогнать миграцию там.
- Добавить столбец и индекс, если планируются пакетные обновления. ALTER TABLE на большой таблице в MySQL 8.0 и MariaDB выполняется онлайн, но требует места на диске под перестройку.
- Задеплоить код с двойной проверкой. Вход продолжает работать со старым хешем, при первом успешном логине появляется новый.
- Следить за долей перехешированных строк: SELECT COUNT(*) FROM users WHERE password_hash_v2 IS NULL.
- Через один-два месяца отправить оставшимся пользователям ссылку на смену пароля, переименовать старый столбец в password_hash_legacy и отозвать право на его чтение у приложения.
Тонкость: hash_equals() вместо == защищает сравнение строк от timing-атак, а параметры Argon2id фиксируйте в конфиге приложения, иначе при смене значений по умолчанию придётся перехешировать всё заново. Откат делается просто: пока старый столбец существует, предыдущий релиз кода возвращается в любой момент.
После миграции остаётся рутина: раз в квартал сверять вывод SHOW GRANTS, отслеживать записи с устаревшим префиксом хеша и проверять, что бэкапы расшифровываются тем же ключом, который вы уже тестировали при восстановлении.