Защита хранилища паролей от брутфорса и атак по словарю: практическое руководство | AdminWiki

Защита хранилища паролей от брутфорса и атак по словарю: практическое руководство

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

Почему брутфорс и атаки по словарю всё ещё работают

Пароль остаётся самым слабым звеном аутентификации: его можно угадать, подобрать по словарю или взять готовым из чужой утечки. Компрометация учётных данных годами держится в топе причин утечек в отчёте Verizon DBIR, а OWASP относит слабую аутентификацию к ключевым рискам приложений. Вывод для практики: защита строится одновременно на уровне приложения, reverse proxy и инфраструктуры, и один слой никогда не заменяет другой.

Полный набор мер укладывается в пять пунктов. Медленный алгоритм хеширования с высоким cost factor. Ограничение числа попыток входа. Блокировка и капча против онлайн-перебора. Структурные логи без секретов. Сравнение строк с постоянным временем везде, где сверяются токены и хеши. Дальше разберём каждый пункт с конкретными значениями и конфигурациями.

Чем отличается брутфорс от атаки по словарю

Брутфорс перебирает все комбинации символов выбранного алфавита. Пароль из шести цифр даёт 10^6 вариантов, из восьми строчных латинских букв - около 2*10^11. Чистый перебор быстро упирается в длину пароля, поэтому на практике его применяют к коротким секретам: PIN-кодам, шестизначным одноразовым кодам, коротким токенам.

Атака по словарю опирается на заранее подготовленные списки. База rockyou.txt содержит около 14,3 млн уникальных паролей из реальных утечек, SecLists добавляет подборки имён, брендов и топ-10000. Списки почти не используют в первозданном виде: hashcat применяет правила мутации (best64.rule, OneRuleToRuleThemStill), которые меняют регистр, заменяют "a" на "@", "o" на "0", дописывают год или восклицательный знак. Двадцать таких правил превращают 14 млн строк в сотни миллионов кандидатов при почти нулевой дополнительной цене.

Отсюда разная эффективность. Против человеческих паролей вида "P@ssw0rd2026" словарь срабатывает за минуты, против случайной строки из 16 символов не работает вообще. Третий сценарий - credential stuffing: злоумышленник берёт пары логин-пароль с чужих сайтов и проверяет их у вас. Подбирать ничего не нужно, достаточно повторить чужую утечку, и на крупных сервисах доля успешных входов в таких кампаниях измеряется процентами.

Онлайн- и офлайн-атаки: разные стратегии защиты

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

Меры делятся соответственно. Против онлайн-перебора работают rate limiting, блокировка учётных записей, капча и задержки между попытками. Против офлайн-перебора работает только медленный алгоритм хеширования с высоким cost factor и уникальная соль для каждого пароля. Ограничение скорости здесь бесполезно, потому что запросов к серверу нет. Если в базе лежат MD5 или SHA-1, никакие сетевые защиты не спасут дамп после утечки.

Порядок величин показывает разрыв. Одна современная видеокарта считает десятки миллиардов MD5 в секунду и тысячи проверок bcrypt с cost=12 в секунду. Разница в семь порядков, и именно она определяет, займёт подбор словаря часы или десятилетия.

Выбор cost factor для медленных алгоритмов хеширования

Cost factor (work factor) задаёт объём работы при одном хешировании. Смысл параметра в асимметрии: легитимный пользователь платит задержкой в доли секунды один раз при входе, атакующий платит той же задержкой за каждого кандидата в словаре.

Рабочее правило: время одного хеширования на целевом сервере должно укладываться в 100-500 мс при реальной нагрузке. Меньше 100 мс - слишком дешёво для перебора. Больше 500 мс - вход заметно тормозит, а пик запросов на форму входа превращается в отказ в обслуживании.

bcrypt: как выбрать cost и не сломать вход

Минимум для 2026 года - cost=12. На обычном серверном CPU одно хеширование при cost=12 занимает порядка 200-300 мс, при cost=10 - около 60-80 мс, при cost=14 - около секунды. Каждая единица удваивает работу, поэтому cost=14 без замера на конкретном железе легко превращает всплеск входов в очередь запросов.

У bcrypt есть жёсткое ограничение: он учитывает только первые 72 байта пароля, остальное молча отбрасывается. Парольная фраза из 100 символов и её 72-символьный префикс дадут одинаковый хеш. Для длинных паролей делают pre-hash: считают SHA-256 от пароля, кодируют результат в base64 и уже его подают на вход bcrypt. Кодирование нужно, чтобы избежать нулевых байтов в результате хеширования.

// генерация и проверка с автоматическим rehash при смене cost
$cost = 12;
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => $cost]);

if (password_verify($password, $hash)) {
    if (password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => $cost])) {
        $newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => $cost]);
        // UPDATE users SET password_hash = :newHash WHERE id = :id
    }
}

Функция password_needs_rehash закрывает главную эксплуатационную проблему: при росте стоимости железа вы поднимаете cost, и хеши обновляются по одному при следующих входах пользователей. Массовая принудительная смена паролей не нужна.

Argon2id: memory cost, iterations и parallelism

Argon2id выиграл Password Hashing Competition и рекомендован OWASP как основной выбор для новых систем. Его сила в memory cost: GPU-атака требует вычислений и большого объёма видеопамяти на каждый параллельный поток, а видеопамяти всегда меньше, чем вычислительных ядер.

RFC 9106 описывает профиль с memory_cost 64 МБ, time_cost 3 и parallelism 4. Минимальный профиль OWASP скромнее: 19 МБ, 2 итерации, parallelism 1. Ориентируйтесь на ресурсы сервера: 100 одновременных входов при 64 МБ на хеш потребуют до 6,4 ГБ RAM, если все они совпадут по времени.

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from argon2.low_level import Type

ph = PasswordHasher(
    time_cost=3,
    memory_cost=65536,   # 64 MiB
    parallelism=4,
    hash_len=32,
    salt_len=16,
    type=Type.ID,        # Argon2id
)

stored = ph.hash(password)

try:
    ph.verify(stored, password)
except VerifyMismatchError:
    # неверный пароль
    ...

if ph.check_needs_rehash(stored):
    stored = ph.hash(password)

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

scrypt и PBKDF2: когда они уместны

scrypt тоже требует много памяти, но встречается реже: у него меньше проверенных реализаций и неудобная настройка maxmem. Базовый профиль: N=2^17 (131072), r=8, p=1, что даёт около 128 МБ памяти на одну проверку.

import hashlib, os

salt = os.urandom(16)
dk = hashlib.scrypt(
    password.encode(), salt=salt,
    n=2**17, r=8, p=1, dklen=32,
    maxmem=256 * 1024 * 1024,   # иначе Python выбросит ValueError
)

PBKDF2 выбирают, когда нужен сертифицированный алгоритм, например для соответствия FIPS 140. К GPU-атакам он устойчив слабее Argon2id, поэтому компенсируют числом итераций: не менее 600 000 для PBKDF2-HMAC-SHA256 и не менее 210 000 для SHA-512 по текущим рекомендациям OWASP.

import hashlib, os

salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000, dklen=32)

Для Node.js выбор сводится к двум библиотекам: bcrypt (нативный аддон) или argon2. Обе умеют работать асинхронно и не блокируют event loop, если не использовать sync-версии.

const bcrypt = require('bcrypt');

const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(password, hash);

Параметры пересматривают по мере роста железа раз в 1-2 года. Пересчитывать хеши заранее не нужно: rehash при следующем успешном входе даёт тот же результат без нагрузки на пользователей.

Настройка блокировки учётных записей и rate limiting

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

Как выбрать порог и время блокировки

Отправная точка: 5 неудачных попыток, блокировка на 15 минут. Для админских и финансовых аккаунтов разумнее 3 попытки и 30 минут. Вместо фиксированной паузы применяйте экспоненциальную задержку: 1 с, 2 с, 4 с, 8 с, 16 с после каждой неудачной попытки. Она замедляет перебор и не мешает человеку, который просто ошибся раскладкой.

Критерий блокировки важнее порога. Блокировка только по IP ломается о NAT: за одним адресом могут стоять сотни сотрудников офиса, и одна атака отключит их всех. Блокировка только по логину даёт злоумышленнику инструмент отключения конкретного пользователя. Рабочая схема - считать неудачные попытки по паре IP+логин, а жёсткий бан по IP включать при явной аномалии, например 50 попыток с одного адреса за минуту по разным логинам.

Сообщение о блокировке должно быть информативным для владельца аккаунта: сколько осталось ждать и куда писать, если попытки не его. Скрытая блокировка без объяснения порождает поток заявок в поддержку.

Rate limiting на Nginx и в приложении

Nginx ограничивает скорость до того, как запрос дойдёт до приложения. Зона считается по $binary_remote_addr, лимит для формы входа ставят в диапазоне 5-10 запросов в минуту.

limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

server {
    location = /login {
        limit_req zone=login burst=3 nodelay;
        limit_req_status 429;
        proxy_pass http://app_backend;
    }
}

Параметр burst разрешает короткий всплеск, nodelay отдаёт ответ сразу вместо ожидания в очереди. Без лимита на уровне приложения proxy-ограничение обходится через прокси-сети и пулы облачных адресов, поэтому защиту дублируют в коде.

# Flask-Limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(key_func=get_remote_address, app=app,
                  default_limits=["200 per day", "50 per hour"])

@app.route("/login", methods=["POST"])
@limiter.limit("5 per minute")
def login():
    ...

Готовые конфигурации limit_req, limit_conn, мониторинг и автоматизация блокировок разобраны в материале про защиту Nginx от DDoS-атак. Там же показано, как снимать метрики в Prometheus и подключать автоматический бан.

Fail2ban для защиты SSH и веб-приложений

Fail2ban читает логи и добавляет правила в firewall при срабатывании фильтра. Базовая конфигурация для SSH:

[sshd]
enabled  = true
port     = ssh
filter   = sshd
logpath  = /var/log/auth.log
maxretry = 3
findtime = 600
bantime  = 3600

Для веб-формы пишут фильтр по коду ответа 401 или 403 в логе Nginx:

[Definition]
failregex = ^ - .* "(POST|GET) /login[^"]*" (401|403)
ignoreregex =

Учитывайте два ограничения сразу. Fail2ban не поможет против распределённой атаки с тысяч адресов, для неё нужны средства уровня CDN или CrowdSec с общими списками репутации. И если приложение стоит за Cloudflare, балансировщиком или другим прокси, все запросы придут с одного адреса: сначала настройте real_ip, иначе первый же бан отрежет весь трафик. Стратегии блокировки адресов и подсетей в iptables, nftables и CrowdSec собраны в статье про блокировку IP-адресов.

Секреты, включая ключи подписи токенов сброса пароля, держите вне репозитория и вне переменных окружения пода: Vault, External Secrets или Sealed Secrets в Kubernetes. Если секрет утёк вместе с дампом базы, все хеши становятся бесполезны.

Логирование неудачных попыток входа без утечки хешей и чувствительных данных

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

Какие поля логировать, а какие - нет

Обязательный минимум: timestamp в UTC, тип события, IP источника, идентификатор пользователя, результат, причина отказа, число попыток за окно. Идентификатор пользователя лучше хранить как хеш логина: сопоставлять события можно, а восстановить имя из лога без словаря сложно.

Запрещённые поля: пароль в любом виде, хеш пароля, соль, секрет TOTP, токен сброса, session_id, заголовок Authorization, тело запроса к форме входа целиком. Дамп логов с хешами превращает офлайн-атаку в тривиальную задачу, а логирование пароля в открытом виде отдаёт атакующему все учётные данные сразу.

IP маскируйте: вместо 203.0.113.47 пишите 203.0.113.0, сохраняя подсеть для корреляций. Пользовательский агент храните в виде хеша SHA-256, чтобы группировать запросы от одного клиента без сбора отпечатков в открытом виде.

{"ts":"2026-09-15T12:00:00Z","event":"login_failed","ip":"203.0.113.0",
 "user_hash":"a1b2c3d4e5f6","reason":"bad_password","attempts":4,
 "ua_hash":"9f2c8b...","request_id":"01J8Z9K3"}

Формат JSON выбран не случайно: его разбирают Logstash, Fluentd, Vector и Loki без регулярных выражений. Retention задавайте явно: 90 дней горячего хранения, дальше архив или удаление. Логи с идентификаторами подпадают под GDPR и 152-ФЗ, поэтому доступ к ним ограничивают ролями, а выгрузку фиксируют. Приёмы маскировки PII и настройки RBAC в Kibana и Grafana описаны в руководстве про защиту данных в логах.

Настройка алертов на попытки подбора

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

# Loki / LogQL: больше 100 отказов за 5 минут по одному IP
sum by (ip) (count_over_time({job="app"} |= "login_failed" [5m])) > 100

Для Elasticsearch правило выглядит как условие на количество документов с event=login_failed по одному IP за окно в 5 минут. Отдельно стоит завести алерт на успешный вход после серии отказов: это признак того, что пароль подобрали. Уведомления отправляйте в один канал (Slack, Telegram, почта), иначе поток сообщений утомит дежурного и настоящая атака пройдёт незамеченной. Готовые команды grep и awk для поиска таких событий в логах Nginx и Apache собраны в разборе логов веб-серверов.

Защита от timing-атак: сравнение строк с постоянным временем

Обычное сравнение строк завершается на первом несовпадающем байте. Функция возвращает false быстрее, если отличается первый символ, и медленнее, если совпали первые десять. По разнице времени ответа можно побайтово восстановить секрет, когда у атакующего тысячи попыток и стабильные замеры.

# уязвимо: ранний выход по первому расхождению
if user_token == stored_token:
    grant_access()

Для атаки нужна повторяемость: одна и та же операция, одинаковые условия, минимум сетевого шума. В обычной жизни замеры шумные, но в локальной сети и при большом числе запросов разница в наносекунды становится различимой. Защита стоит недорого, поэтому её включают по умолчанию.

Примеры constant-time сравнения на PHP, Python и Node.js

// PHP
if (!hash_equals($expectedToken, $userToken)) {
    http_response_code(403);
    exit;
}
# Python
import hmac

if not hmac.compare_digest(expected_token, user_token):
    raise PermissionError("invalid token")
// Node.js
const crypto = require('crypto');

function safeEqual(expected, actual) {
  const a = Buffer.from(expected);
  const b = Buffer.from(actual);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Три детали, на которых чаще всего спотыкаются. crypto.timingSafeEqual выбрасывает исключение при разной длине буферов, поэтому длину проверяют заранее. Сравнивать нужно сами секреты, а не их хеши: сверка SHA-256 от токенов тоже требует constant-time, иначе смысл теряется. И функции различаются по поведению: PHP hash_equals корректно обрабатывает строки разной длины, но не спасает от утечки через длину ввода.

Сравнение хешей паролей через password_verify или argon2.verify уже реализовано с постоянным временем. Ручная сверка результата password_hash через == ломает защиту, даже если сам алгоритм выбран верно.

Где ещё важна защита от timing-атак

Список мест, которые проверяют при аудите: проверка токена сброса пароля, валидация API-ключа, сравнение HMAC-подписи вебхука, сверка сессионного идентификатора, проверка одноразового кода TOTP, сравнение CSRF-токена. Каждое из этих мест с == или equals() становится отдельным каналом утечки, а основной вход при этом может быть защищён безупречно.

Аудит делается поиском по коду: ищите == рядом с переменными, в имени которых есть token, secret, key, hash, signature. В Go для безопасной сверки используют crypto/subtle.ConstantTimeCompare, в Java - MessageDigest.isEqual. Отдельно ищите ранний выход: конструкция if (!valid) return false внутри цикла по байтам сама создаёт уязвимость.

Капча и дополнительные барьеры против автоматизированного перебора

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

Когда капча уместна, а когда вредит

Показывайте капчу после 3 неудачных попыток с одной пары IP+логин, при входе с нового устройства или страны, при частоте запросов выше обычной для пользователя. На каждом входе капча раздражает и снижает конверсию, особенно в мобильных приложениях.

Из вариантов практичны три: невидимая reCAPTCHA v3 (оценка риска без действий пользователя), hCaptcha и Cloudflare Turnstile. Turnstile не требует аккаунта Google и работает как виджет с последующей серверной проверкой токена: токен живёт 300 секунд и одноразовый. Клиентскую часть подключают скриптом, серверную - запросом к API проверки, где контролируют поле success и параметр hostname, чтобы токен не переиспользовали на другом домене.

Альтернатива для API и CLI-клиентов - proof-of-work: сервер выдаёт задачу, клиент тратит 50-200 мс CPU на поиск решения и присылает результат вместе с паролем. Атакующий платит ту же цену за каждую попытку, а легитимный клиент - один раз. Сложность подбирают так, чтобы на слабом телефоне решение занимало меньше секунды.

Для аккаунтов с высокой ценностью добавьте барьер, который словарь не обходит: второй фактор. TOTP или аппаратный ключ FIDO2 делают украденный пароль бесполезным, даже если он подобрался.

Как оценить эффективность защиты: метрики и тестирование

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

Бенчмарк хеширования на своём железе

import time
import bcrypt

rounds = 12
start = time.perf_counter()
for _ in range(100):
    bcrypt.hashpw(b"correct horse battery staple", bcrypt.gensalt(rounds=rounds))
elapsed = time.perf_counter() - start
print(f"cost={rounds}: {elapsed / 100 * 1000:.1f} ms на хеш")

Замер делайте на том же сервере и в том же окружении, где работает приложение: виртуализация, лимиты CPU в контейнере и соседние процессы заметно меняют результат. Прогоните тест при целевой нагрузке, а не на пустой машине. Если хеширование укладывается в 100-500 мс и не съедает больше 10-15% CPU при пиковых входах, параметры подобраны верно.

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

Расчёт времени до взлома

Формула простая: время = число кандидатов / скорость перебора в секунду. Скорость берите не теоретическую, а измеренную на сопоставимом GPU.

# hashcat: сравнение двух режимов на одном словаре
hashcat -m 0    -a 0 md5_hashes.txt    rockyou.txt
hashcat -m 3200 -a 0 bcrypt_hashes.txt rockyou.txt -r best64.rule
# посмотреть восстановленные пароли
hashcat -m 3200 bcrypt_hashes.txt --show

Расчёт для rockyou с мутациями: около 10 млн кандидатов. При скорости 1000 проверок в секунду полный проход занимает примерно 2,7 часа, при 100 проверках в секунду - около 27 часов на одну видеокарту. Сервер с четырьмя GPU сокращает это в разы, но не в тысячи раз, и в этом весь смысл cost factor: он превращает часы в недели, а недели в годы.

Мультипликатор, который часто забывают: словарь без мутаций покрывает меньшую долю реальных паролей. Правила best64.rule и OneRuleToRuleThemStill увеличивают пространство кандидатов в десятки раз, но и требования ко времени растут пропорционально. Для оценки своих пользователей проверьте корпоративные адреса по базам утечек: если пароль уже встречался в публичных дампах, его подберут на первой секунде независимо от cost.

Типичные ошибки и антипаттерны

Большинство провалов в защите хранилища паролей вызваны не экзотическими атаками, а базовыми решениями, принятыми годами ранее и не пересмотренными.

Почему MD5 и SHA-1 не подходят для паролей

MD5 и SHA-1 проектировали для проверки целостности, где скорость - достоинство. Для паролей скорость работает против вас: одна современная видеокарта перебирает десятки миллиардов MD5 в секунду против тысяч проверок bcrypt с cost=12. Соль задачу не решает: она убивает радужные таблицы, но оставляет скорость перебора, а значит словарь с мутациями всё равно проходит за минуты.

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

Ошибки в блокировке аккаунтов

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

Бан только по IP отключает целые офисы за NAT: сотни людей получат отказ из-за одного атакующего в той же сети. Считайте попытки по паре IP+логин, а по IP блокируйте лишь при явной аномалии.

Отсутствие уведомления превращает инцидент в загадку: человек видит отказ и не понимает причину. Сообщайте о блокировке, показывайте время разблокировки и канал связи с поддержкой.

Слишком высокий cost factor - ошибка в другую сторону. Значение, при котором хеширование занимает 2 секунды, превращает сотню одновременных входов в отказ сервиса. Замеряйте время на целевом железе и оставляйте запас на пики.

Логирование паролей и хешей в открытом виде, сравнение секретов через ==, отсутствие мониторинга, один и тот же cost factor без пересмотра годами: каждая из этих ошибок по отдельности обнуляет остальные меры. Разумный минимум, который стоит зафиксировать в стандарте разработки: Argon2id или bcrypt для хранения, лимиты и блокировки на входе, структурные логи без секретов, constant-time сравнение во всех местах, где сверяются токены.

Чек-лист защиты хранилища паролей

  1. Перевести хранение паролей на bcrypt с cost не ниже 12 или на Argon2id с параметрами 64 МБ, 3 итерации, 4 потока.
  2. Убедиться, что соль уникальна для каждого пароля и берётся из криптостойкого генератора.
  3. Добавить pre-hash SHA-256 в base64 для паролей длиннее 72 байт, если используется bcrypt.
  4. Включить rehash при следующем успешном входе, чтобы смена cost не требовала сброса паролей.
  5. Ограничить форму входа в Nginx: rate=5r/m, burst=3, limit_req_status 429.
  6. Продублировать лимит в приложении с привязкой к паре IP+логин.
  7. Настроить блокировку после 5 неудачных попыток на 15 минут с экспоненциальной задержкой.
  8. Включить fail2ban для SSH (maxretry=3, bantime=3600) и для веб-формы входа.
  9. Проверить real_ip, если приложение стоит за CDN или балансировщиком.
  10. Добавить капчу (Turnstile или reCAPTCHA v3) после 3 неудачных попыток.
  11. Включить второй фактор для админских и привилегированных аккаунтов.
  12. Перевести логи входов в JSON без паролей, хешей, солей и токенов; маскировать IP до подсети.
  13. Задать retention 90 дней и ограничить доступ к логам ролями.
  14. Настроить алерт на 100 и более отказов за 5 минут по одному IP и на успешный вход после серии отказов.
  15. Заменить сравнение секретов через == на hash_equals, hmac.compare_digest, crypto.timingSafeEqual или crypto/subtle.ConstantTimeCompare.
  16. Замерить время хеширования на целевом сервере, пересматривать cost раз в 1-2 года и потребовать смену паролей, найденных в публичных утечках.
Поделиться:
Сохранить гайд? В закладки браузера