Пароль хранят не в открытом виде и не как SHA-256, а как медленный хеш с уникальной солью: OWASP рекомендует Argon2id минимум с 19 MiB памяти, двумя проходами и одним потоком. Рецепт даёт готовый модуль для Node.js 24 — хеширование, проверку, перехеширование при смене параметров, переход со старых bcrypt-хешей при входе и проверку несуществующего email за то же время, что и настоящего.
После материала вы сможете
- хешировать пароль Argon2id с параметрами OWASP (
m=19456,t=2,p=1) и проверить его функциейverifyPassword - перехешировать пароль при входе, если в базе хеш со старыми параметрами или от bcrypt
- выровнять время ответа для несуществующего email хешем-заглушкой и проверить это тестом
- объяснить, почему SHA-256 и шифрование не подходят для хранения паролей
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)argon2 0.45.1bcryptjs 3.0.3TypeScript 7.0.2 (strict)Vitest 5.0.3
Сверено с документацией: 12 октября 2026 г.
Содержание
Задача: утечка базы не должна раскрыть пароли
Базу рано или поздно могут украсть: SQL-инъекция, забытая резервная копия, лишний доступ у подрядчика. OWASP Password Storage Cheat Sheet ставит требование так: пароли должны оставаться защищёнными, даже если скомпрометированы приложение или база данных. Поэтому сервер хранит не пароль, а хеш — результат односторонней функции. При входе он заново вычисляет хеш введённого пароля и сравнивает с сохранённым.
Почему не SHA-256. Обычные хеш-функции созданы, чтобы считаться быстро, а это на руку атакующему: с украденной базой он перебирает на видеокартах миллиарды кандидатов из словарей и прошлых утечек. OWASP прямо называет SHA-256 непригодным для паролей. Нужна медленная функция с настраиваемой стоимостью (work factor) и солью — случайным значением, уникальным для каждого пароля: одинаковые пароли дают разные хеши, а заранее посчитанные таблицы бесполезны. Шифрование тоже не подходит: оно обратимо, и вместе с ключом атакующий получит все пароли сразу.
Argon2id
- Когда применять
- Первый выбор
- Минимальные параметры
m=19456(19 MiB),t=2,p=1или равноценные: 46 MiB иt=1, 12 MiB иt=3, 9 MiB иt=4, 7 MiB иt=5
scrypt
- Когда применять
- Если Argon2id недоступен
- Минимальные параметры
N=2^17(128 MiB),r=8,p=1
bcrypt
- Когда применять
- Только унаследованные системы
- Минимальные параметры
- Work factor от 10, пароль не длиннее 72 байт
PBKDF2
- Когда применять
- Если нужно соответствие FIPS-140
- Минимальные параметры
- 600 000 итераций HMAC-SHA-256
Хеш Argon2 хранится в PHC-формате — одной строкой, где записаны алгоритм, версия, параметры, соль и сам результат: $argon2id$v=19$m=19456,p=1,t=2$<соль>$<хеш>. Отдельные колонки для соли и параметров не нужны, а смена параметров не ломает старые записи: каждая строка помнит, как её посчитали.
Готовый код: password.ts и проверка при входе
Рецепт состоит из двух файлов. password.ts хеширует и проверяет пароль и сообщает, пора ли заменить хеш. login.ts — вход поверх любого хранилища пользователей: таблицы PostgreSQL, ORM или внешнего сервиса. Для старых bcrypt-хешей используется bcryptjs — реализация на чистом JavaScript без нативной сборки.
npm init -y
npm pkg set type=module
npm install argon2@0.45 bcryptjs@3
npm install -D typescript @types/node@24 vitest{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test", "scripts"]
}.ts запускаются встроенным type stripping Node.js 24import * as argon2 from "argon2";
import bcrypt from "bcryptjs";
// OWASP Password Storage Cheat Sheet: Argon2id, at least m=19 MiB, t=2, p=1.
// Benchmark on your server: raise memoryCost or timeCost while a hash stays well under a second.
export const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19_456, // KiB, i.e. 19 MiB
timeCost: 2,
parallelism: 1,
} as const;
export type VerifyResult =
| { valid: false }
// newHash !== null: store it instead of the old hash
| { valid: true; newHash: string | null };
export function hashPassword(password: string): Promise<string> {
// A new random salt every time; algorithm, parameters and salt go into the PHC string
return argon2.hash(password, ARGON2_OPTIONS);
}
const isArgon2 = (hash: string) => hash.startsWith("$argon2");
const isBcrypt = (hash: string) => /^\$2[aby]\$\d{2}\$/.test(hash);
export async function verifyPassword(password: string, storedHash: string): Promise<VerifyResult> {
if (isArgon2(storedHash)) {
if (!(await argon2.verify(storedHash, password))) return { valid: false };
// needsRehash compares m, t, p and the version, but not the variant (argon2i, argon2d)
const outdated = !storedHash.startsWith("$argon2id$") || argon2.needsRehash(storedHash, ARGON2_OPTIONS);
return { valid: true, newHash: outdated ? await hashPassword(password) : null };
}
if (isBcrypt(storedHash)) {
// Legacy hash: check it with bcrypt one last time and replace it with Argon2id
if (!(await bcrypt.compare(password, storedHash))) return { valid: false };
return { valid: true, newHash: await hashPassword(password) };
}
throw new Error("Unknown password hash format");
}ARGON2_OPTIONS— минимальная конфигурация OWASP. Параметры пакета по умолчанию (64 MiB, 3 прохода, 4 потока) строже; это «второй рекомендуемый вариант» RFC 9106. Какие бы вы ни выбрали, задайте их явно: тогдаneedsRehashсравнивает хеши с известной конфигурацией, а обновление пакета не меняет её незаметно.- Соль пакет генерирует сам для каждого хеша — передавать свою не нужно.
argon2.verifyберёт параметры и соль из строки хеша и сравнивает результат черезtimingSafeEqualизnode:crypto(так в исходниках версии 0.45.1): время сравнения не зависит от того, сколько байт совпало.needsRehashсравнивает память, проходы, потоки и версию, но не вариант алгоритма, поэтому префикс$argon2id$проверяется отдельно.- Неизвестный формат — исключение, а не
false. По нашему опыту, повреждённый хеш лучше увидеть в логах ошибок, чем молча отказывать пользователю во входе.
import { randomBytes } from "node:crypto";
import { hashPassword, verifyPassword } from "./password.ts";
export type UserRecord = { id: string; email: string; passwordHash: string };
export interface UserStore {
findByEmail(email: string): Promise<UserRecord | null>;
updatePasswordHash(userId: string, passwordHash: string): Promise<void>;
}
// A hash of a random password with the current parameters. An unknown email is checked
// against it, so the answer takes as long as for a real account with a wrong password.
let dummyHash: Promise<string> | undefined;
const getDummyHash = () => (dummyHash ??= hashPassword(randomBytes(32).toString("base64url")));
export async function authenticate(
store: UserStore,
email: string,
password: string,
): Promise<UserRecord | null> {
const user = await store.findByEmail(email.trim().toLowerCase());
const result = await verifyPassword(password, user?.passwordHash ?? (await getDummyHash()));
if (!user || !result.valid) return null;
if (result.newHash !== null) {
// Rehash on login: new parameters or a migration from bcrypt
await store.updatePasswordHash(user.id, result.newHash);
return { ...user, passwordHash: result.newHash };
}
return user;
}Если email не найден, authenticate всё равно выполняет полную проверку — против хеша-заглушки. Без неё ответ «такого пользователя нет» приходил бы за доли миллисекунды, а «неверный пароль» — за десятки, и по времени можно было бы узнать, зарегистрирован ли адрес. OWASP Authentication Cheat Sheet требует в обоих случаях одинакового сообщения и примерно одинакового времени ответа. Заглушка вычисляется один раз при первом обращении и всегда имеет текущие параметры.
Как подключить: колонка, параметры и миграция
Колонка для хеша
Тип
textилиvarchar(255). Строка Argon2id с параметрами рецепта занимает 97 символов, bcrypt — 60; колонкаchar(60)из времён bcrypt новый хеш не вместит.Хранилище
Реализуйте
UserStore:findByEmailиupdatePasswordHash— два запроса к своей таблице. Email храните в нижнем регистре:authenticateнормализует ввод так же.Регистрация и смена пароля
await hashPassword(password)— и сохраните строку целиком.Вход
authenticate(store, email, password). Ответnullпревращается в одно общее сообщение «неверный email или пароль».Замер на сервере
Запустите скрипт ниже на той же конфигурации, что и прод, и выберите параметры не ниже минимума OWASP.
import * as argon2 from "argon2";
// Run on the production server (or the same instance type), not on a laptop
const candidates = [
{ memoryCost: 19_456, timeCost: 2 }, // OWASP minimum
{ memoryCost: 47_104, timeCost: 1 }, // OWASP alternative: 46 MiB, t=1
{ memoryCost: 65_536, timeCost: 3 }, // RFC 9106 second recommended option (p=4 there)
];
for (const params of candidates) {
const options: argon2.HashOptions = { type: argon2.argon2id, parallelism: 1, ...params };
await argon2.hash("warm-up", options);
const start = performance.now();
for (let i = 0; i < 10; i++) await argon2.hash("benchmark password", options);
const ms = (performance.now() - start) / 10;
console.log(`m=${params.memoryCost} KiB, t=${params.timeCost}, p=1: ${ms.toFixed(1)} ms per hash`);
}$ node scripts/bench.ts
m=19456 KiB, t=2, p=1: 21.8 ms per hash
m=47104 KiB, t=1, p=1: 33.7 ms per hash
m=65536 KiB, t=3, p=1: 115.1 ms per hashOWASP подчёркивает, что универсального значения нет: стоимость подбирают замером на своём железе, и хеш должен считаться меньше секунды. Слишком высокая стоимость превращает форму входа в инструмент отказа в обслуживании — каждая попытка занимает память и процессор. Память считается на одну проверку: при 19 MiB пятьдесят одновременных входов займут около 950 MiB.
Переход с bcrypt OWASP описывает так: перехешировать пароль при следующем входе. Заранее ничего делать не нужно — строки $2b$… остаются в той же колонке, verifyPassword узнаёт их по префиксу, проверяет через bcryptjs и возвращает новый хеш Argon2id. Повышение параметров Argon2id работает так же: поменяли ARGON2_OPTIONS — хеши обновляются по мере входов.
Как проверить
Тесты на Vitest проверяют поведение: формат и параметры хеша, уникальную соль, перехеширование при старых параметрах и другом варианте алгоритма, переход с bcrypt только после верного пароля, неизменность хеша после ошибки и время ответа для неизвестного email.
import * as argon2 from "argon2";
import bcrypt from "bcryptjs";
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "../src/password.ts";
const PASSWORD = "correct horse battery staple";
const CURRENT = /^\$argon2id\$v=19\$m=19456,p=1,t=2\$/;
describe("hashPassword / verifyPassword", () => {
it("produces an Argon2id PHC string with the OWASP parameters and a unique salt", async () => {
const first = await hashPassword(PASSWORD);
const second = await hashPassword(PASSWORD);
expect(first).toMatch(CURRENT);
expect(first).not.toBe(second);
});
it("accepts the right password and rejects a wrong one", async () => {
const hash = await hashPassword(PASSWORD);
expect(await verifyPassword(PASSWORD, hash)).toEqual({ valid: true, newHash: null });
expect(await verifyPassword("wrong password", hash)).toEqual({ valid: false });
});
it("returns a new hash when the stored one uses old parameters", async () => {
const old = await argon2.hash(PASSWORD, { type: argon2.argon2id, memoryCost: 12_288, timeCost: 3, parallelism: 1 });
const result = await verifyPassword(PASSWORD, old);
expect(result).toEqual({ valid: true, newHash: expect.stringMatching(CURRENT) });
});
it("returns a new hash for argon2i with the same parameters", async () => {
const old = await argon2.hash(PASSWORD, { type: argon2.argon2i, memoryCost: 19_456, timeCost: 2, parallelism: 1 });
const result = await verifyPassword(PASSWORD, old);
expect(result).toEqual({ valid: true, newHash: expect.stringMatching(CURRENT) });
});
it("migrates a legacy bcrypt hash only after the right password", async () => {
const legacy = await bcrypt.hash(PASSWORD, 10);
expect(await verifyPassword("wrong password", legacy)).toEqual({ valid: false });
const result = await verifyPassword(PASSWORD, legacy);
expect(result).toEqual({ valid: true, newHash: expect.stringMatching(CURRENT) });
});
it("refuses an unknown hash format instead of guessing", async () => {
await expect(verifyPassword(PASSWORD, "5e884898da28047151d0e56f8dc6292773603d0d")).rejects.toThrow(
"Unknown password hash format",
);
});
});import bcrypt from "bcryptjs";
import { beforeEach, describe, expect, it } from "vitest";
import { authenticate, type UserRecord, type UserStore } from "../src/login.ts";
import { hashPassword } from "../src/password.ts";
class InMemoryUsers implements UserStore {
readonly byEmail = new Map<string, UserRecord>();
updates = 0;
async findByEmail(email: string) {
return this.byEmail.get(email) ?? null;
}
async updatePasswordHash(userId: string, passwordHash: string) {
for (const user of this.byEmail.values()) {
if (user.id === userId) user.passwordHash = passwordHash;
}
this.updates++;
}
}
const PASSWORD = "correct horse battery staple";
let store: InMemoryUsers;
beforeEach(async () => {
store = new InMemoryUsers();
store.byEmail.set("anna@example.com", { id: "u1", email: "anna@example.com", passwordHash: await hashPassword(PASSWORD) });
store.byEmail.set("old@example.com", { id: "u2", email: "old@example.com", passwordHash: await bcrypt.hash(PASSWORD, 10) });
});
async function medianMs(run: () => Promise<unknown>, times = 5): Promise<number> {
const results: number[] = [];
for (let i = 0; i < times; i++) {
const start = performance.now();
await run();
results.push(performance.now() - start);
}
return results.sort((a, b) => a - b)[Math.floor(times / 2)]!;
}
describe("authenticate", () => {
it("logs in with the right password and keeps a current hash", async () => {
const user = await authenticate(store, " Anna@Example.com ", PASSWORD);
expect(user?.id).toBe("u1");
expect(store.updates).toBe(0);
});
it("returns null for a wrong password and for an unknown email", async () => {
expect(await authenticate(store, "anna@example.com", "wrong password")).toBeNull();
expect(await authenticate(store, "nobody@example.com", PASSWORD)).toBeNull();
});
it("moves a bcrypt user to Argon2id on login", async () => {
const user = await authenticate(store, "old@example.com", PASSWORD);
expect(user?.passwordHash).toMatch(/^\$argon2id\$/);
expect(store.byEmail.get("old@example.com")?.passwordHash).toMatch(/^\$argon2id\$/);
expect(await authenticate(store, "old@example.com", PASSWORD)).not.toBeNull();
expect(store.updates).toBe(1);
});
it("does not touch the stored hash after a wrong password", async () => {
const before = store.byEmail.get("old@example.com")?.passwordHash;
await authenticate(store, "old@example.com", "wrong password");
expect(store.byEmail.get("old@example.com")?.passwordHash).toBe(before);
expect(store.updates).toBe(0);
});
it("spends comparable time on an unknown email and on a wrong password", async () => {
await authenticate(store, "warm-up@example.com", PASSWORD); // the dummy hash is computed once
const unknown = await medianMs(() => authenticate(store, "nobody@example.com", PASSWORD));
const wrong = await medianMs(() => authenticate(store, "anna@example.com", "wrong password"));
expect(unknown).toBeGreaterThan(wrong * 0.5);
});
});$ npx tsc -p .
$ npx vitest run --reporter=verbose
RUN v5.0.3 …
✓ test/password.test.ts > hashPassword / verifyPassword > produces an Argon2id PHC string with the OWASP parameters and a unique salt 49ms
✓ test/login.test.ts > authenticate > logs in with the right password and keeps a current hash 115ms
✓ test/password.test.ts > hashPassword / verifyPassword > accepts the right password and rejects a wrong one 66ms
✓ test/password.test.ts > hashPassword / verifyPassword > returns a new hash when the stored one uses old parameters 64ms
✓ test/login.test.ts > authenticate > returns null for a wrong password and for an unknown email 165ms
✓ test/password.test.ts > hashPassword / verifyPassword > returns a new hash for argon2i with the same parameters 78ms
✓ test/password.test.ts > hashPassword / verifyPassword > migrates a legacy bcrypt hash only after the right password 234ms
✓ test/password.test.ts > hashPassword / verifyPassword > refuses an unknown hash format instead of guessing 1ms
✓ test/login.test.ts > authenticate > moves a bcrypt user to Argon2id on login 216ms
✓ test/login.test.ts > authenticate > does not touch the stored hash after a wrong password 145ms
✓ test/login.test.ts > authenticate > spends comparable time on an unknown email and on a wrong password 303ms
Test Files 2 passed (2)
Tests 11 passed (11)Хороший тест падает, если убрать защиту. При проверке рецепта по очереди удаляли хеш-заглушку, вызов needsRehash, проверку варианта argon2id и сохранение нового хеша — каждый раз падал ровно один профильный тест, после возврата кода проходили все 11.
Ограничения и что добавить
- Лимит попыток входа по аккаунту и по IP. Медленный хеш затрудняет перебор украденной базы, но не перебор через форму входа
- Длина пароля: по OWASP со ссылкой на NIST SP 800-63B пароль короче 15 символов без MFA считается слабым, максимальная длина — не меньше 64 символов, обрезать пароль молча нельзя
- bcrypt учитывает только первые 72 байта, а кириллическая буква в UTF-8 занимает 2 байта. Для старых хешей это уже свершилось; у Argon2id такого ограничения нет
- Проверка по утечкам: OWASP советует блокировать распространённые и уже утёкшие пароли, например через сервис Pwned Passwords
- Pepper — общий секрет вне базы (опция
secretвargon2.hashиargon2.verify) защищает, если утекла только база. Сменить его без знания паролей нельзя: OWASP описывает это как принудительный сброс паролей. Храните pepper как секрет, не в репозитории - Пока не завершён переход с bcrypt, вход «старого» пользователя длится иначе, чем проверка хеша-заглушки: при проверке рецепта bcrypt с work factor 10 занимал около 64 мс против 25 мс у Argon2id. По времени такой аккаунт можно отличить от несуществующего; если это важно, сбросьте старые хеши, не дожидаясь входов
Альтернативы npm-пакету. В Node.js с версии 24.7.0 есть встроенная crypto.argon2(), но она возвращает сырые байты: PHC-строку, проверку и аналог needsRehash придётся писать самостоятельно — это сравнение приводит README node-argon2. crypto.scrypt() есть давно, однако его параметры по умолчанию (N=16384) ниже рекомендации OWASP, а для N=2^17 и r=8 нужно поднять maxmem: по умолчанию он 32 MiB, а расчёт требует около 128 MiB, иначе Node.js выбрасывает ошибку о превышении лимита памяти.
Связанные материалы и источники
- «Аутентификация: сессии, cookies и JWT» — где этот модуль стоит в схеме входа: сессия, cookie, защита от CSRF
- «Авторизация: роли, права и проверка доступа на сервере» — следующий шаг после входа
- «Секреты и переменные окружения: как не утечь ключами» — где хранить pepper и другие секреты
- «OWASP Top 10 для разработчика» — пароли и вход в общей картине уязвимостей
Параметры в рецепте сверены с OWASP Password Storage Cheat Sheet на дату в шапке. Рекомендации пересматриваются по мере роста мощности оборудования — при обновлении проекта сверяйте их заново и поднимайте ARGON2_OPTIONS: перехеширование при входе сделает остальное.
Как проверено3 · пройденосверено 12 октября 2026 г.
- запущено, работает
tsconfig.json, src/password.ts, src/login.ts, scripts/bench.ts, test/password.test.ts, test/login.test.ts
временный проект scratchpad/kb/verify/recept-heshirovanie-parolej: tsc -p . (TypeScript 7.0.2, strict) без ошибок; vitest run — 11/11 (password 6, login 5), три прогона подряд стабильно зелёные; Node.js 24.18.0, argon2 0.45.1 (prebuilt win32-x64, install-скрипт заблокирован npm 11.16 allow-scripts — пакет работает), bcryptjs 3.0.3, Vitest 5.0.3, @types/node 24.19.2
- запущено, работает
мутационная проверка тестов
по одной мутации: без хеша-заглушки (ранний return для неизвестного email), без needsRehash, без проверки префикса $argon2id$, без updatePasswordHash — каждый раз падал ровно 1 профильный тест (10 passed | 1 failed); после отката 11/11
- запущено, работает
node scripts/bench.ts (вывод в статье), замеры в тексте
запуск через type stripping Node 24: 21.8 / 33.7 / 115.1 мс на хеш; отдельный замер verify: bcryptjs cost 10 ≈ 64 мс, Argon2id m=19456,t=2,p=1 ≈ 25 мс; длина PHC-строки Argon2id 97 символов, bcrypt 60; crypto.scryptSync N=2^17 r=8 без maxmem → ERR_CRYPTO_INVALID_SCRYPT_PARAMS (memory limit exceeded), с maxmem 129 MiB — работает
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.