Хранение паролей в PostgreSQL: pgcrypto, crypt() и gen_salt() на практике | AdminWiki

Хранение паролей в PostgreSQL: pgcrypto, crypt() и gen_salt() на практике

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

Расширение pgcrypto даёт PostgreSQL собственные функции для работы с паролями: crypt() считает хеш, gen_salt() генерирует соль. Этого достаточно, чтобы регистрировать пользователей и проверять пароли прямо в SQL, без отдельного слоя в приложении. Минимальный рабочий пример выглядит так:

SELECT crypt('secret', gen_salt('bf', 10));

На выходе получается строка вида $2a$10$..., в которой закодированы алгоритм bcrypt, параметр стоимости 10 и соль. Отдельное поле для соли не нужно: crypt() достаёт её из сохранённого хеша.

У этого удобства есть цена. Пароль попадает в текст SQL-запроса, а текст запроса может оказаться в логе сервера (log_statement = 'all') и в статистике pg_stat_statements. Для публичного сервиса такой путь рискован, для внутреннего инструмента или прототипа он вполне рабочий. Ниже разобраны установка, синтаксис, готовые запросы и критерии выбора между базой и приложением.

Зачем хешировать пароли в PostgreSQL и когда это оправдано

pgcrypto это расширение из стандартной поставки PostgreSQL (contrib). Оно добавляет crypt() и gen_salt() для паролей, digest() для хешей, pgp_sym_encrypt() для симметричного шифрования. Компилировать ничего не нужно, файлы расширения лежат рядом с сервером и подключаются одной командой.

Хеширование паролей в базе закрывает конкретный класс задач: приложение не умеет хешировать (bash-скрипт, ETL-процесс, самописная админка), отдельного бэкенда нет, есть только PostgreSQL и клиент к нему, нужно быстро проверить гипотезу на прототипе. Типовой пример: небольшой внутренний сервис, где вся логика аутентификации выражается парой SQL-запросов.

Что даёт pgcrypto на практике: bcrypt с настраиваемым cost factor, автоматическую соль внутри хеша, единый формат $2a$/$2b$, совместимый с библиотеками bcrypt в приложениях. Последнее важно при миграции: хеши, созданные crypt(), читаются библиотекой bcrypt в PHP, Python или Go, и наоборот.

Ограничение подхода: набор алгоритмов ограничен четырьмя вариантами - bf, xdes, md5, des. Argon2id, scrypt и PBKDF2 недоступны. Для нового публичного сервиса это весомый минус, для служебного инструмента - нет.

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

Установка и настройка pgcrypto в PostgreSQL

Включение выполняется одной командой в той базе, где будут храниться пользователи:

CREATE EXTENSION IF NOT EXISTS pgcrypto;

Нужны права суперпользователя или роль с привилегией CREATE на базу и доверием к расширению. В managed-инстансах (Timeweb Cloud, Amazon RDS, Яндекс Managed PostgreSQL) команда обычно доступна владельцу базы. Если провайдер ведёт белый список расширений, pgcrypto включают через консоль или параметр shared_preload_libraries для отдельных сборок.

Проверка после установки:

SELECT crypt('test', gen_salt('bf'));

Ответ вида $2a$06$... означает, что всё работает. Пустой результат или ошибка "function crypt(text, text) does not exist" говорят о том, что расширение не включено в этой базе, либо подключение идёт к другой базе или к реплике с иным набором расширений. Ошибка "permission denied to create extension" решается ролью с нужными правами.

Расширение создаётся в конкретной базе. Если приложение подключается к другой базе, команду нужно повторить там. На реплике расширение появится, если оно установлено на основном сервере до создания реплики.

Поведение crypt() и gen_salt() стабильно на PostgreSQL 13 и новее: набор алгоритмов и синтаксис не менялись. Дополнительной настройки autovacuum или планировщика расширение не требует.

Функции crypt() и gen_salt(): синтаксис и алгоритмы

gen_salt(type, iter_count) возвращает соль в формате, который понимает crypt(). Поддерживаются четыре типа:

ТипАлгоритмiter_countСтатус
bfbcrypt (Blowfish)cost factor, 4-31, по умолчанию 6рекомендуется
xdesрасширенный DESчисло итераций, по умолчанию 8устарел
md5MD5 с сольюне используетсянебезопасен
desклассический DESне используетсянебезопасен

crypt(password, salt) возвращает строку хеша, в которой закодированы алгоритм, параметры и соль. Пример на bcrypt с cost 10:

SELECT crypt('mypassword', gen_salt('bf', 10));
-- $2a$10$N9qo8uLOickgx2ZMRZoMye...

Тот же пароль с той же солью даёт одинаковый хеш, с новой солью - другой. На этом строится проверка: при аутентификации crypt() получает соль и параметры из сохранённого хеша.

md5 и des оставлены для совместимости с очень старыми системами. Оба алгоритма ломаются перебором на доступном железе. Новый проект не должен применять эти типы.

Алгоритм bf (bcrypt): параметры и производительность

Cost factor задаёт число раундов и вместе с ним время вычисления. Ориентиры для одного потока на современном серверном x86:

  • cost = 10: около 100 мс на хеш;
  • cost = 12: около 400 мс;
  • cost = 14: порядка 1,5 с.

Рабочий диапазон для продакшена: 10-12. Cost 10 даёт приемлемую задержку входа и делает перебор в тысячи раз дороже одного раунда. Cost 12 усиливает стойкость, но заметен пользователю: почти полсекунды ожидания, а всплеск параллельных входов нагружает CPU базы. Значение выше 12 применяют выборочно, например для административных учётных записей.

bcrypt включает соль в результат crypt(), поэтому поле salt в таблице не требуется. Соль уникальна для каждой записи: gen_salt() при каждом вызове возвращает новое значение.

Как подобрать cost factor под своё железо и закрыть перебор, описано в руководстве по защите хранилища паролей от брутфорса и атак по времени: там есть бенчмарк, настройки rate limiting и чек-лист из 16 пунктов.

Алгоритм xdes: когда ещё используется и почему не рекомендуется

xdes это расширенный DES с восемью итерациями по умолчанию. Он остался ради совместимости: хеши вида _J9..rasmBYk8r9AiWNc из старых установок PostgreSQL и PHP читаются и проверяются до сих пор.

Разница в стоимости перебора нагляднее любых оценок. bcrypt с cost 10 считает хеш около 100 мс, xdes - меньше 1 мс, то есть примерно в 1000 раз быстрее. Ограничение длины пароля в 8 символов у классического DES и слабая стойкость делают xdes непригодным для новых проектов. Материал, где хеши новых пользователей пишутся в xdes, стоит переписать.

Оправданный сценарий для xdes один: миграция legacy-базы. Тогда применяют двойной хеш: проверяют старое значение, при успешном входе пересчитывают пароль в bcrypt и сохраняют результат.

Практические SQL-запросы для регистрации и проверки паролей

Схема таблицы пользователей:

CREATE TABLE users (
    id            SERIAL PRIMARY KEY,
    login         TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX users_login_idx ON users (lower(login));

Индекс по login нужен для быстрого поиска при входе, выражение lower(login) закрывает регистронезависимый логин. Дополнительных индексов тут не требуется, кроме случаев, когда на таблицу ложится тяжёлая выборка вместе с аутентификацией. В такой ситуации пригодятся приёмы из материала о медленных запросах и способах их ускорения.

Регистрация пользователя: INSERT с crypt() и gen_salt()

INSERT INTO users (login, password_hash)
VALUES ($1, crypt($2, gen_salt('bf', 10)))
RETURNING id;

$1 и $2 - параметры, передаваемые драйвером. Конкатенация строк в SQL недопустима: она открывает SQL-инъекцию. gen_salt('bf', 10) вызывается на каждую вставку и создаёт новую соль, поэтому одинаковые пароли двух пользователей дают разные хеши.

Две частые ошибки: фиксированная соль в константе для всей таблицы и слишком короткое поле password_hash. Строка bcrypt с префиксом $2a$10$ занимает 60 символов, для xdes достаточно 20, для md5 - 35. Тип TEXT снимает вопрос с запасом.

Проверка пароля: SELECT с crypt() и сравнение

SELECT id
FROM users
WHERE login = $1
  AND password_hash = crypt($2, password_hash);

crypt($2, password_hash) читает соль и параметры из сохранённого значения, пересчитывает хеш от введённого пароля и возвращает строку в том же формате. Равенство строк даёт ответ. При неверном логине или пароле запрос вернёт 0 строк.

Слабое место этого запроса: сравнение в SQL не гарантирует постоянное время выполнения, а планировщик может вычислять условия в произвольном порядке. Теоретическая timing-атака по такому запросу требует точных измерений и большого числа попыток, но в системах с высокими требованиями проверку переносят в приложение: сначала выбирают password_hash по login, затем сверяют хеши функцией с constant-time сравнением (password_verify() в PHP, hmac.compare_digest() в Python) и добавляют rate limiting.

Второй момент: запрос с открытым паролем в тексте попадает в логи и статистику. Подробнее об этом в разделе про риски.

Смена пароля и обновление хеша

UPDATE users
SET password_hash = crypt($1, gen_salt('bf', 10))
WHERE id = $2
  AND password_hash = crypt($3, password_hash);

Условие по password_hash проверяет старый пароль в том же запросе. Новая соль генерируется при каждом обновлении, старая не переиспользуется. Ответ UPDATE 1 подтверждает смену, UPDATE 0 означает, что старый пароль не совпал или пользователя нет.

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

Хеширование в БД или в приложении: сравнение подходов

Критерийpgcrypto в базеПриложение
Открытый пароль в логах СУБДвозможеннет
Доступные алгоритмыbf, xdes, md5, desArgon2id, bcrypt, scrypt, PBKDF2
Смена параметров costSQL на каждого пользователяв коде, централизованно
Правки в приложениине нужнынужны
Сравнение constant-timeне гарантированоесть в библиотеках

Сильная сторона pgcrypto: одна точка правды. Логика аутентификации лежит в схеме базы, приложению достаточно выполнить два запроса, а миграция на новый сервер переносит хеши без изменений. Слабая сторона: открытый пароль проходит через SQL-слой.

Риски хеширования на стороне БД: логи и утечки

Пароль входит в текст запроса, а текст запроса может сохраниться в четырёх местах:

  • лог сервера при log_statement = 'all' или 'mod';
  • pg_stat_statements, который хранит текст и параметры запроса;
  • журнал ошибок: PostgreSQL пишет невыполненный запрос целиком;
  • логи slow query и auto_explain при срабатывании log_min_duration_statement.

Одна строка в логе с crypt('secret', gen_salt('bf', 10)) обесценивает любое хеширование: пароль стоит рядом с ней в открытом виде. Ротация логов сокращает срок жизни утечки, но не отменяет её. Проверка логов на такие строки входит в регулярный аудит: набор запросов и проверок есть в руководстве по аудиту безопасности баз данных.

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

Когда всё же стоит использовать pgcrypto

Оправданные сценарии:

  • прототип или MVP, где скорость сборки важнее строгих требований к защите;
  • внутренний инструмент в закрытом контуре, без доступа из интернета;
  • приложение, которое нельзя менять (вендорное, замороженное);
  • разовые SQL-скрипты миграции: массовый пересчёт хешей при смене алгоритма;
  • учебные и тестовые стенды, где нужен предсказуемый результат без кода.

В этих случаях минимизируют логирование паролей и оставляют bcrypt с cost не ниже 10. Если база живёт в облаке, включить pgcrypto помогает провайдер: в Timeweb Cloud расширения managed-базы PostgreSQL доступны из панели, а сам сервер можно разместить рядом с приложением и закрыть доступ по сети.

Для публичного сервиса с персональными данными хеширование переносят в приложение. В PHP это password_hash() с PASSWORD_ARGON2ID, в Python - passlib или argon2-cffi, в Go - golang.org/x/crypto/bcrypt. Пароль не покидает процесс, выбор алгоритма и параметров контролируется в коде, а смена cost factor не требует SQL-миграции.

Ограничения pgcrypto и альтернативные решения

Что стоит учитывать при выборе:

  • нет Argon2id и scrypt: лучший доступный вариант - bcrypt с cost 10-12;
  • устаревшие md5, des, xdes остаются в наборе и легко применяются по ошибке;
  • параметры cost нельзя задать глобально: gen_salt('bf', 10) придётся писать в каждом запросе;
  • вычисления идут на CPU сервера базы и конкурируют с обычными запросами: при сотне входов в секунду с cost 12 это уже сотни ядро-секунд нагрузки;
  • pgcrypto не управляет жизненным циклом паролей: политики, срок действия, история хешей и сброс остаются на приложении;
  • проверка пароля не защищена от timing-атак на уровне SQL.

Альтернативы: библиотеки хеширования в приложении (bcrypt, Argon2id), внешние системы аутентификации (Keycloak, OAuth-провайдеры), а также pgcrypto только для шифрования данных, не для паролей. Шифрование при передаче и хранении, включая TLS для соединений с базой и защиту бэкапов, разбирается в отдельном материале о шифровании данных при передаче и хранении.

Безопасность и аудит: как защитить пароли при использовании pgcrypto

Если выбор сделан в пользу pgcrypto, риски снижают набором конкретных настроек.

  • Логирование. Установите log_statement = 'none' или 'ddl'. Значение 'mod' уже создаёт риск, а 'all' неприемлемо для базы с аутентификацией.
  • pg_stat_statements. Расширение полезно для разбора медленных запросов, но хранит текст обращений. Проверьте, сохраняются ли параметры в вашей версии, и очищайте статистику по расписанию.
  • log_min_duration_statement. Порог в миллисекундах означает, что медленный INSERT с crypt() попадёт в лог вместе с паролем. Для базы с аутентификацией порог либо отключают, либо исключают проблемные запросы.
  • Права. Приложению нужны только SELECT, INSERT и UPDATE на таблицу users. Отдельная роль без доступа к pg_authid и без прав на другие схемы уменьшает последствия компрометации.
  • Сеть. Даже при хешировании в базе пароль идёт по сети в открытом виде. Соединение приложения с PostgreSQL закрывают TLS: проверяют ssl = on и строку hostssl в pg_hba.conf.
  • Проверка логов. Раз в неделю ищите в логах строки с crypt(, gen_salt( и password. Найденное означает утечку и требует смены паролей пользователей.
  • Ротация. Срок хранения логов ограничивают, старые архивы шифруют или удаляют.

Отдельно про бэкапы: дамп базы содержит хеши, а не пароли, что уже защищает пользователей. Однако дамп, снятый во время отладки с log_statement = 'all', может притащить лог с открытыми паролями. Проверяйте содержимое архивов перед отправкой на внешнее хранилище.

Итоги: рекомендации по выбору подхода

pgcrypto с crypt() и gen_salt('bf') закрывает базовую задачу: хеширование и проверку паролей без кода в приложении. Для продакшена с внешними пользователями предпочтительнее хеширование в приложении с bcrypt или Argon2id: пароль не попадает ни в SQL, ни в логи, а параметры алгоритма меняются в одном месте.

Если pgcrypto уже используется или выбран осознанно, держитесь правил: алгоритм bf, cost 10-12, новая соль на каждую запись, параметризованные запросы, log_statement = 'none', TLS для соединений, права приложения ограничены таблицей users.

СценарийРекомендация
Публичный сервис, персональные данныехеширование в приложении, Argon2id или bcrypt
Внутренний инструмент, закрытый контурpgcrypto, bf, cost 10-12
Прототип, MVPpgcrypto допустим, с планом перехода
Legacy с xdes или md5двойной хеш и постепенный пересчёт в bcrypt
Нужны политики паролей, MFA, SSOвнешний провайдер аутентификации

Перед переносом запросов в продакшен сверьтесь с разделом pgcrypto официального руководства PostgreSQL для вашей версии сервера: список поддерживаемых типов соли и границы iter_count лучше проверять на месте, а не по памяти.

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