Как делать12 октября 2026 г.7 мин чтения
Backend на Node.jsШаг 8 из 8

Рецепт: хранение паролей — хеширование и проверка

Коротко

Пароль хранят не в открытом виде и не как SHA-256, а как медленный хеш с уникальной солью: OWASP рекомендует Argon2id минимум с 19 MiB памяти, двумя проходами и одним потоком. Рецепт даёт готовый модуль для Node.js 24 — хеширование, проверку, перехеширование при смене параметров, переход со старых bcrypt-хешей при входе и проверку несуществующего email за то же время, что и настоящего.

Рецепт кодаУровень: СреднийРецепты аутентификацииПрактика: ~20–40 мин

После материала вы сможете

  • хешировать пароль 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 г.

Содержание
  1. 01Задача: утечка базы не должна раскрыть пароли
  2. 02Готовый код: password.ts и проверка при входе
  3. 03Как подключить: колонка, параметры и миграция
  4. 04Как проверить
  5. 05Ограничения и что добавить
  6. 06Связанные материалы и источники

Задача: утечка базы не должна раскрыть пароли

Базу рано или поздно могут украсть: 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
Рекомендации OWASP Password Storage Cheat Sheet на дату сверки

Хеш Argon2 хранится в PHC-формате — одной строкой, где записаны алгоритм, версия, параметры, соль и сам результат: $argon2id$v=19$m=19456,p=1,t=2$<соль>$<хеш>. Отдельные колонки для соли и параметров не нужны, а смена параметров не ломает старые записи: каждая строка помнит, как её посчитали.

Готовый код: password.ts и проверка при входе

Рецепт состоит из двух файлов. password.ts хеширует и проверяет пароль и сообщает, пора ли заменить хеш. login.ts — вход поверх любого хранилища пользователей: таблицы PostgreSQL, ORM или внешнего сервиса. Для старых bcrypt-хешей используется bcryptjs — реализация на чистом JavaScript без нативной сборки.

bash
npm init -y
npm pkg set type=module
npm install argon2@0.45 bcryptjs@3
npm install -D typescript @types/node@24 vitest
Зависимости; версии, на которых проверен рецепт, — в шапке
tsconfig.json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test", "scripts"]
}
Строгий режим TypeScript; файлы .ts запускаются встроенным type stripping Node.js 24
src/password.ts
import * 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");
}
Хеширование, проверка, перехеширование и переход с bcrypt
  • ARGON2_OPTIONS — минимальная конфигурация OWASP. Параметры пакета по умолчанию (64 MiB, 3 прохода, 4 потока) строже; это «второй рекомендуемый вариант» RFC 9106. Какие бы вы ни выбрали, задайте их явно: тогда needsRehash сравнивает хеши с известной конфигурацией, а обновление пакета не меняет её незаметно.
  • Соль пакет генерирует сам для каждого хеша — передавать свою не нужно.
  • argon2.verify берёт параметры и соль из строки хеша и сравнивает результат через timingSafeEqual из node:crypto (так в исходниках версии 0.45.1): время сравнения не зависит от того, сколько байт совпало.
  • needsRehash сравнивает память, проходы, потоки и версию, но не вариант алгоритма, поэтому префикс $argon2id$ проверяется отдельно.
  • Неизвестный формат — исключение, а не false. По нашему опыту, повреждённый хеш лучше увидеть в логах ошибок, чем молча отказывать пользователю во входе.
src/login.ts
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, сохранение нового хеша

Если email не найден, authenticate всё равно выполняет полную проверку — против хеша-заглушки. Без неё ответ «такого пользователя нет» приходил бы за доли миллисекунды, а «неверный пароль» — за десятки, и по времени можно было бы узнать, зарегистрирован ли адрес. OWASP Authentication Cheat Sheet требует в обоих случаях одинакового сообщения и примерно одинакового времени ответа. Заглушка вычисляется один раз при первом обращении и всегда имеет текущие параметры.

Как подключить: колонка, параметры и миграция

  1. Колонка для хеша

    Тип text или varchar(255). Строка Argon2id с параметрами рецепта занимает 97 символов, bcrypt — 60; колонка char(60) из времён bcrypt новый хеш не вместит.

  2. Хранилище

    Реализуйте UserStore: findByEmail и updatePasswordHash — два запроса к своей таблице. Email храните в нижнем регистре: authenticate нормализует ввод так же.

  3. Регистрация и смена пароля

    await hashPassword(password) — и сохраните строку целиком.

  4. Вход

    authenticate(store, email, password). Ответ null превращается в одно общее сообщение «неверный email или пароль».

  5. Замер на сервере

    Запустите скрипт ниже на той же конфигурации, что и прод, и выберите параметры не ниже минимума OWASP.

scripts/bench.ts
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`);
}
Замер стоимости хеша для нескольких вариантов параметров
terminal
$ 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 hash
Вывод на машине, где проверялся рецепт (Windows, Node.js 24.18). На вашем сервере цифры будут другими — для этого и нужен замер

OWASP подчёркивает, что универсального значения нет: стоимость подбирают замером на своём железе, и хеш должен считаться меньше секунды. Слишком высокая стоимость превращает форму входа в инструмент отказа в обслуживании — каждая попытка занимает память и процессор. Память считается на одну проверку: при 19 MiB пятьдесят одновременных входов займут около 950 MiB.

Переход с bcrypt OWASP описывает так: перехешировать пароль при следующем входе. Заранее ничего делать не нужно — строки $2b$… остаются в той же колонке, verifyPassword узнаёт их по префиксу, проверяет через bcryptjs и возвращает новый хеш Argon2id. Повышение параметров Argon2id работает так же: поменяли ARGON2_OPTIONS — хеши обновляются по мере входов.

Как проверить

Тесты на Vitest проверяют поведение: формат и параметры хеша, уникальную соль, перехеширование при старых параметрах и другом варианте алгоритма, переход с bcrypt только после верного пароля, неизменность хеша после ошибки и время ответа для неизвестного email.

test/password.test.ts
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",
    );
  });
});
Тесты модуля паролей
test/login.test.ts
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);
  });
});
Тесты входа с хранилищем в памяти
terminal
$ 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)
Реальный вывод: проверка типов без ошибок, 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.