Опубліковано: · Оновлено: · автор: kodewright
Хешування паролів із сіллю: практичний гайд для розробника
Як зберігати паролі у 2026 році: унікальна сіль, параметри Argon2id і bcrypt від OWASP, реальні цифри RTX 4090, перець і міграція старих хешів.

Щоб безпечно зберігати паролі у 2026 році, пропускайте їх через повільну функцію хешування паролів із сіллю: Argon2id щонайменше з 19 MiB пам'яті, 2 ітераціями та 1 потоком (мінімум OWASP) або bcrypt із cost від 10, якщо він уже є у вашому стеку. Нехай бібліотека сама генерує свіжу випадкову сіль для кожного пароля, а ви зберігайте весь закодований рядок, який вона повертає. Сама лише сіль не робить безпечним швидкий хеш на кшталт SHA-256, і більшість помилок, які я бачу в коді автентифікації, походять саме з плутанини між цими двома речами.
Що сіль додає до хешування, а чого ні
Сіль — це випадкове значення, яке генерують для кожного пароля й подають у хеш разом із ним. Її зберігають поруч із хешем у відкритому вигляді: вона потрібна для перевірки, а секретність не є її завданням. RFC 9106 радить для Argon2 128 бітів (16 байтів), NIST SP 800-63B ставить нижню межу в 32 біти.

Унікальна сіль робить дві речі:
- Заздалегідь обчислені таблиці (rainbow tables або просто бази «хеш → пароль») стають марними, бо ніхто не рахував хеші під вашу випадкову сіль.
- Атакувальнику доводиться ламати кожен хеш окремо. Без солі один прохід словника по викраденій таблиці перевіряє всіх користувачів одразу, а двоє людей з однаковим паролем мають однаковий хеш, що вже саме по собі є витоком.
Чого сіль не робить, то це не сповільнює підбір для одного хешу. Якщо функція — SHA-256, атакувальник бере сіль конкретного користувача, дописує її до кожного кандидата і працює на повній швидкості GPU. Сіль множить роботу на кількість користувачів, а ціну кожної спроби визначає хеш-функція.
Чому SHA-256 із сіллю все одно падає швидко
Хеші загального призначення спроєктовані бути швидкими. Ось бенчмарки hashcat 6.2.6 на одній RTX 4090:
| Функція | Спроб за секунду (одна RTX 4090) | Призначена для паролів? |
|---|---|---|
| MD5 | 164,1 млрд | Ні |
| SHA-1 | 50,6 млрд | Ні |
| SHA-256 | 21,98 млрд | Ні |
| SHA-512 | 7,48 млрд | Ні |
| PBKDF2-HMAC-SHA256, 600 000 ітерацій | ~14 800 (перераховано з бенчмарку на 999 ітерацій) | Так |
| bcrypt, cost 10 | ~5 750 (виведено з бенчмарку cost 5) | Так |
| bcrypt, cost 12 | ~1 440 | Так |
Справжні атакувальники не перебирають випадкові рядки, вони запускають словники з правилами модифікації. Візьмімо скромний прогін на 10 млрд кандидатів (скажімо, словник на 10 млн слів і 1 000 правил) проти одного викраденого хешу:
| Як збережено | Час на 10 млрд спроб, одна RTX 4090 |
|---|---|
| MD5 із сіллю | близько 0,06 секунди |
| SHA-256 із сіллю | близько 0,5 секунди |
| PBKDF2-SHA256, 600 тис. ітерацій | близько 8 днів |
| bcrypt cost 10 | близько 20 днів |
| bcrypt cost 12 | близько 80 днів |
Це на одного користувача й одну відеокарту. Атакувальники орендують ферми з вісьмома й більше картами, тож ділите відповідно. Важливе співвідношення: повільні функції роблять кожну спробу в сотні тисяч чи мільйони разів дорожчою, і саме цей розрив дає вашим користувачам час змінити паролі після витоку. Слабкий пароль на кшталт Summer2024! стоїть на початку будь-якого словника й швидко падає проти кожної з цих функцій, тому вибір хешування захищає середину розподілу, а не найгірші паролі.
Вибір алгоритму та параметрів
NIST SP 800-63B (Rev. 4) не називає конкретного алгоритму. Він вимагає, щоб паролі були «посолені й захешовані придатною схемою хешування паролів», сіль мала щонайменше 32 біти, а cost factor був настільки високим, наскільки це практично без шкоди для продуктивності верифікатора. Шпаргалка OWASP Password Storage конкретніша, тож я беру її цифри:
| Алгоритм | Мінімум OWASP | Коли я б його обрав |
|---|---|---|
| Argon2id | m=19 MiB, t=2, p=1 (або m=46 MiB, t=1, p=1) | За замовчуванням для нового коду |
| scrypt | N=2¹⁷ (128 MiB), r=8, p=1 | Argon2id недоступний у вашому рантаймі |
| bcrypt | cost 10 або вище, скільки дозволяє сервер | Наявні системи або фреймворк, де він за замовчуванням |
| PBKDF2-HMAC-SHA256 | 600 000 ітерацій | Потрібні примітиви з валідацією FIPS |
Argon2id — memory-hard функція: кожна спроба мусить заповнити й прочитати задану кількість пам'яті, тож атакувальник платить і обчисленнями, і оперативною пам'яттю. Робочий стан bcrypt зафіксований на рівні близько 4 KiB, хоч який cost ви поставите. Саме тому Argon2id кращий вибір за замовчуванням, а не через якусь слабкість самого bcrypt.
Це мінімуми. Поміряйте на продакшн-залізі й піднімайте cost, доки один хеш не займатиме стільки часу, скільки ваш ендпоінт входу може собі дозволити під навантаженням. І пам'ятайте, що атакувальник, який довбе форму входу, змушує платити цю ціну й вас, тож обмеження частоти запитів потрібне в будь-якому разі.
Argon2id у Node.js без бага з повторним хешуванням
Пакет argon2 за замовчуванням використовує Argon2id із 64 MiB, t=3, p=4: це другий рекомендований варіант з RFC 9106, і він вищий за мінімум OWASP. Якщо змінюєте параметри, тримайте їх в одному об'єкті й передавайте всюди саме його:
import argon2 from "argon2";
const HASH_OPTS = {
type: argon2.argon2id,
memoryCost: 19456, // KiB = 19 MiB, мінімум OWASP
timeCost: 2,
parallelism: 1,
};
export async function hashPassword(password) {
// Сіль генерується всередині; результат містить алгоритм, параметри й сіль:
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
return argon2.hash(password, HASH_OPTS);
}
export async function checkPassword(user, password) {
const ok = await argon2.verify(user.passwordHash, password);
if (ok && argon2.needsRehash(user.passwordHash, HASH_OPTS)) {
await db.users.updateHash(user.id, await argon2.hash(password, HASH_OPTS));
}
return ok;
}
Навіщо спільний об'єкт: needsRehash() підставляє значення бібліотеки за замовчуванням замість кожного параметра, який ви не передали. Захешуйте з parallelism: 1, викличте needsRehash(hash, { memoryCost: 19456, timeCost: 2 }) без parallelism, і функція порівняє p=1 з дефолтним p=4 та повертатиме true при кожному вході. Нічого помітно не ламається, ви просто переписуєте хеш на кожен логін. Я бачив опубліковані фрагменти коду саме з такою розбіжністю, зокрема один, що колись був у цьому блозі.
verify() читає сіль і параметри зі збереженого рядка й порівнює через timingSafeEqual з Node, тож нічого не витягайте й не порівнюйте самі і не тримайте окрему колонку для солі.
Ліміт 72 байти в bcrypt і безпечне попереднє хешування
Більшість реалізацій bcrypt беруть лише перші 72 байти входу, а решту мовчки ігнорують. В UTF-8 це 36 кириличних літер або 18 емодзі, а не 72 символи. Для українськомовних парольних фраз ліміт настає вдвічі швидше, ніж для англійських.
Цей ліміт уже бив по реальних системах. У жовтні 2024 року Okta повідомила, що її делегована автентифікація AD/LDAP формувала ключ кешу, проганяючи через bcrypt рядок userId + username + password. Якщо ім'я користувача мало 52 символи чи більше, пароль опинявся після 72-го байта й не впливав на ключ, тож вхід через кеш міг пройти з будь-яким паролем (за умови вимкненого MFA та ще кількох обставин). Okta виправила це, перевівши цей ключ кешу на PBKDF2.
Якщо вам потрібні довші входи, звичний прийом — попереднє хешування, і наївна його версія небезпечна. Сирий дайджест SHA-256 може містити байт 0x00, а реалізації bcrypt, які сприймають вхід як C-рядок, на ньому зупиняються. Простий bcrypt(sha256(password)) ще й відкриває «password shucking»: атакувальник, у якого є несолений SHA-256 того самого пароля з іншого витоку, може перевірити його напряму проти вашого bcrypt-хешу. Конструкція OWASP закриває обидві проблеми:
bcrypt(base64(HMAC-SHA384(key = pepper, data = password)), salt, cost)
Base64 прибирає нульові байти, а ключ HMAC (перець, про нього нижче) блокує shucking. У новому проєкті я б радше перейшов на Argon2id, ніж підтримував таку конструкцію.
Перець допомагає, лише коли секрет живе окремо
Перець (pepper) — це секретний ключ, який застосовують до кожного пароля й зберігають поза базою даних. NIST SP 800-63B каже, що верифікатори ПОВИННІ (SHOULD) виконувати додаткову ітерацію хешування з ключем або шифрування секретним ключем, відомим лише верифікатору, зберігати його окремо від хешів і бажано всередині HSM.
Загроза, яку він закриває, вузька: витікає база (SQL-ін'єкція, викрадений бекап), а секрети застосунку ні. Якщо атакувальник контролює сервер, у нього є і те, і те. Застосувати перець можна двома способами:
- Після хешування: зберігайте
HMAC-SHA256(pepper, argon2id_hash). Легко додати до наявної таблиці. - До хешування: подавайте
HMAC(pepper, password)у KDF, як у конструкції для bcrypt вище.
Сплануйте ротацію ще до релізу. OWASP зазначає, що перець неможливо змінити без паролів користувачів, тож заміна скомпрометованого означає примусове скидання паролів і зберігання старих перців під ідентифікатором версії, доки користувачі не увійдуть знову.
Що я перевіряю першим, коли рев'юю код із паролями
Більша частина моєї практичної роботи з безпекою — це код-рев'ю, а не написання систем автентифікації з нуля, і ось проблеми, які помітні найшвидше:
- Одна сіль для всіх, часто константа в конфігу. Це майже не краще, ніж без солі: однакові паролі все одно збігаються, а один прохід словника покриває всю таблицю.
- Сіль із
Math.random(). У V8Math.random()— це xorshift128+, і його внутрішній стан відновлюється з кількох вихідних значень. Використовуйтеcrypto.randomBytes(16)у Node абоcrypto.getRandomValues()у браузері, а найпростіше, дайте бібліотеці KDF згенерувати сіль самій. sha256(salt + password), який називають «хешуванням із сіллю». Сіль там справді є, але швидкість лишається 22 млрд спроб за секунду на одну GPU.- Порівняння хешів через
===. Нехай це робитьverify/compareбібліотеки. - Тіла запитів у логах. Дебаг-логер, який вивантажує payload входу, кладе паролі відкритим текстом у сховище логів, і все описане вище вже не має значення. Маскуйте поля з паролями до логування.
- Немає способу підняти cost. Якщо параметри захардкоджені й ніщо не перехешовує паролі під час входу, вони назавжди лишаються на рівні 2019 року.
Перенесення наявної таблиці на кращий хеш
Не можна перехешувати те, чого не можете прочитати, тож міграція складається з двох частин. По-перше, при кожному успішному вході перевіряйте старий хеш, а потім зберігайте свіжий Argon2id-хеш пароля, який користувач щойно ввів. По-друге, для тих, хто вже не повернеться, загорніть старі хеші одразу: argon2id(md5_hash), із прапорцем у кожному рядку, щоб код входу знав, що спершу треба обчислити md5(password). OWASP називає це нашаруванням (layering) і наголошує, що це тимчасовий захід: замінюйте такі хеші прямими під час наступного входу й подумайте про примусове скидання пароля для акаунтів, які так і лишаються неактивними.
Якщо хочете побачити різницю у швидкості на власні очі, захешуйте тестовий рядок у Генераторі хешів: він працює повністю у вашому браузері й нікуди не надсилає введене, а SHA-256 оновлюється просто під час набору. Потім спробуйте той самий рядок у Генераторі bcrypt і пересуньте повзунок cost від 10 до 14, щоб побачити, як росте оцінка часу на один хеш. Про внутрішню будову bcrypt та історію MD5 читайте в статті що таке bcrypt і чому MD5 мертвий, а що ці швидкості означають з боку користувача, пояснено в матеріалі скільки часу потрібно, щоб зламати пароль.
FAQ
Чим хешування відрізняється від солі?
Хешування — це одностороння функція, яка перетворює пароль на дайджест. Сіль — випадкове значення для кожного пароля, яке додають перед хешуванням. Вона робить хеші однакових паролів різними й змушує атакувальника ламати кожен акаунт окремо, але не сповільнює підбір для конкретного хешу.
Чи можна хешувати паролі через SHA-256?
Ні. Це надійний хеш для перевірки цілісності та підписів, але одна RTX 4090 обчислює близько 22 млрд хешів SHA-256 за секунду, із сіллю чи без. Атака словником на 10 млрд спроб проти одного SHA-256 із сіллю займає близько пів секунди, а проти bcrypt cost 10 на тій самій карті — близько 20 днів.
Що обрати у 2026 році: Argon2id чи bcrypt?
Для нового коду беріть Argon2id, починаючи з m=19 MiB, t=2, p=1, або дефолти пакета argon2 (64 MiB, t=3, p=4), якщо сервери їх витримують. Залиште bcrypt, якщо він уже працює і cost не нижчий за 10, а на Argon2id переходьте через перехешування під час входу, коли наступного разу візьметеся за код автентифікації.
Що таке перець (pepper) і чи потрібен він?
Перець — це секретний ключ, однаковий для всіх користувачів, який застосовують через HMAC до або після хешування пароля і тримають поза базою даних. Його варто додати, якщо ваші секрети лежать у сховищі чи HSM, до якого витік бази не дістанеться. Якщо атакувальник отримає базу разом зі змінними середовища, користі від нього мало.