How to buildOctober 12, 20269 min read
Backend with Node.jsStep 8 of 8

Recipe: storing passwords — hashing and verification

In short

Passwords are stored neither in plain text nor as SHA-256, but as a slow hash with a unique salt: OWASP recommends Argon2id with at least 19 MiB of memory, two passes and one lane. This recipe gives you a ready-made Node.js 24 module — hashing, verification, rehashing when the parameters change, migration from old bcrypt hashes on login, and a check for an unknown email that takes as long as for a real one.

Code recipeLevel: IntermediateAuthentication RecipesPractice: ~20–40 min

After this you will be able to

  • hash a password with Argon2id using the OWASP parameters (m=19456, t=2, p=1) and verify it with verifyPassword
  • rehash a password on login when the stored hash has old parameters or comes from bcrypt
  • equalise the response time for an unknown email with a dummy hash and check it with a test
  • explain why SHA-256 and encryption are not suitable for storing passwords

Verified with

Node.js 24 LTS (24.18)argon2 0.45.1bcryptjs 3.0.3TypeScript 7.0.2 (strict)Vitest 5.0.3

Checked against docs: October 12, 2026

Contents
  1. 01The task: a database leak must not reveal passwords
  2. 02The code: password.ts and the login check
  3. 03How to plug it in: column, parameters and migration
  4. 04How to check it
  5. 05Limitations and what to add
  6. 06Related materials and sources

The task: a database leak must not reveal passwords

Sooner or later a database can be stolen: an SQL injection, a forgotten backup, a contractor with too much access. The OWASP Password Storage Cheat Sheet puts the requirement this way: passwords must stay protected even if the application or the database is compromised. So the server stores a hash — the output of a one-way function — instead of the password. On login it hashes the entered password again and compares the result with the stored one.

Why not SHA-256. General-purpose hash functions are designed to be fast, and that helps the attacker: with a stolen database they try billions of candidates from wordlists and earlier breaches on GPUs. OWASP explicitly calls SHA-256 unsuitable for passwords. You need a slow function with a tunable cost (work factor) and a salt — a random value unique to each password: equal passwords produce different hashes, and precomputed tables become useless. Encryption does not fit either: it is reversible, and together with the key the attacker gets every password at once.

Argon2id

When to use
First choice
Minimum parameters
m=19456 (19 MiB), t=2, p=1 or equivalent: 46 MiB with t=1, 12 MiB with t=3, 9 MiB with t=4, 7 MiB with t=5

scrypt

When to use
When Argon2id is not available
Minimum parameters
N=2^17 (128 MiB), r=8, p=1

bcrypt

When to use
Legacy systems only
Minimum parameters
Work factor of 10 or more, password up to 72 bytes

PBKDF2

When to use
When FIPS-140 compliance is required
Minimum parameters
600,000 iterations of HMAC-SHA-256
OWASP Password Storage Cheat Sheet recommendations as of the check date

An Argon2 hash is stored in the PHC string format — a single string holding the algorithm, version, parameters, salt and the result itself: $argon2id$v=19$m=19456,p=1,t=2$<salt>$<hash>. You need no separate columns for the salt and the parameters, and changing the parameters does not break old records: each string remembers how it was computed.

The code: password.ts and the login check

The recipe has two files. password.ts hashes and verifies a password and tells you whether the hash should be replaced. login.ts handles login on top of any user store: a PostgreSQL table, an ORM or an external service. Old bcrypt hashes are checked with bcryptjs, a pure JavaScript implementation with no native build.

bash
npm init -y
npm pkg set type=module
npm install argon2@0.45 bcryptjs@3
npm install -D typescript @types/node@24 vitest
Dependencies; the versions the recipe was checked with are listed in the header
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"]
}
Strict TypeScript; .ts files run with the built-in type stripping of 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");
}
Hashing, verification, rehashing and migration from bcrypt
  • ARGON2_OPTIONS is the OWASP minimum configuration. The package defaults (64 MiB, 3 passes, 4 lanes) are stronger; they match the second recommended option of RFC 9106. Whatever you pick, set it explicitly: then needsRehash compares hashes with a known configuration, and a package update cannot change it silently.
  • The package generates a new salt for every hash — you do not pass your own.
  • argon2.verify reads the parameters and the salt from the hash string and compares the result with timingSafeEqual from node:crypto (that is what the 0.45.1 source does): the comparison time does not depend on how many bytes match.
  • needsRehash compares memory, passes, lanes and version, but not the algorithm variant, so the $argon2id$ prefix is checked separately.
  • An unknown format throws instead of returning false. In our experience, a corrupted hash is better seen in the error logs than silently locking a user out.
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;
}
Login: the same path for a known and an unknown email, the new hash is saved

When the email is not found, authenticate still runs a full check — against a dummy hash. Without it, «no such user» would come back in a fraction of a millisecond and «wrong password» in tens of milliseconds, and the timing would reveal whether the address is registered. The OWASP Authentication Cheat Sheet requires the same message and roughly the same response time in both cases. The dummy hash is computed once, on first use, and always has the current parameters.

How to plug it in: column, parameters and migration

  1. Hash column

    Use text or varchar(255). An Argon2id string with the recipe parameters is 97 characters long, a bcrypt one is 60; a char(60) column from the bcrypt days will not fit the new hash.

  2. Store

    Implement UserStore: findByEmail and updatePasswordHash are two queries to your table. Keep emails in lower case: authenticate normalises the input the same way.

  3. Sign-up and password change

    await hashPassword(password) — and store the whole string.

  4. Login

    authenticate(store, email, password). A null result becomes a single generic message: «invalid email or password».

  5. Benchmark on the server

    Run the script below on the same configuration as production and pick parameters no lower than the OWASP minimum.

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`);
}
Measuring the hash cost for several parameter sets
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
Output on the machine the recipe was checked on (Windows, Node.js 24.18). Your server will show different numbers — that is why you measure

OWASP stresses that there is no universal value: you tune the cost by measuring on your own hardware, and a hash should take less than a second. Too high a cost turns the login form into a denial-of-service tool — every attempt consumes memory and CPU. Memory is per check: at 19 MiB, fifty concurrent logins take about 950 MiB.

OWASP describes the migration from bcrypt like this: rehash the password the next time the user logs in. Nothing has to be done in advance — the $2b$… strings stay in the same column, verifyPassword recognises them by the prefix, checks them with bcryptjs and returns a new Argon2id hash. Raising the Argon2id parameters works the same way: change ARGON2_OPTIONS, and hashes are upgraded as users log in.

How to check it

The Vitest tests check behaviour: the hash format and parameters, a unique salt, rehashing for old parameters and another algorithm variant, migration from bcrypt only after the right password, an unchanged hash after a wrong one, and the response time for an unknown 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",
    );
  });
});
Tests for the password module
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);
  });
});
Login tests with an in-memory store
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)
Real output: the type check passes, 11 tests (your timings will differ)

A good test fails when the protection is removed. While checking the recipe we removed, one at a time, the dummy hash, the needsRehash call, the argon2id variant check and saving the new hash — each time exactly one relevant test failed, and with the code restored all 11 passed.

Limitations and what to add

  • Rate-limit login attempts per account and per IP. A slow hash slows down cracking a stolen database, but not guessing through the login form
  • Password length: OWASP, citing NIST SP 800-63B, treats a password shorter than 15 characters without MFA as weak, the maximum length must be at least 64 characters, and passwords must never be truncated silently
  • bcrypt only uses the first 72 bytes, and a Cyrillic letter takes 2 bytes in UTF-8. For old hashes this has already happened; Argon2id has no such limit
  • Breached passwords: OWASP advises blocking common and previously breached passwords, for example with the Pwned Passwords service
  • A pepper — a shared secret outside the database (the secret option of argon2.hash and argon2.verify) — helps when only the database leaks. It cannot be changed without knowing the passwords: OWASP describes this as forcing a password reset. Store the pepper as a secret, not in the repository
  • Until the bcrypt migration is complete, a login of a «legacy» user takes a different time than checking the dummy hash: while checking the recipe, bcrypt with work factor 10 took about 64 ms versus 25 ms for Argon2id. Timing can tell such an account from a non-existent one; if that matters, reset the old hashes instead of waiting for logins

Alternatives to the npm package. Since version 24.7.0 Node.js has a built-in crypto.argon2(), but it returns raw bytes: you would write the PHC string, the verification and a needsRehash equivalent yourself — the node-argon2 README lists this comparison. crypto.scrypt() has been around for a long time, but its defaults (N=16384) are below the OWASP recommendation, and for N=2^17 with r=8 you have to raise maxmem: it defaults to 32 MiB while the computation needs about 128 MiB, otherwise Node.js throws a memory limit error.

Related materials and sources

  • «Authentication: sessions, cookies and JWT» — where this module fits into the login flow: session, cookie, CSRF protection
  • «Authorization: roles, permissions and server-side access checks» — the next step after login
  • «Secrets and environment variables: how not to leak your keys» — where to keep the pepper and other secrets
  • «OWASP Top 10 for developers» — passwords and login in the overall picture of vulnerabilities

The parameters in this recipe were checked against the OWASP Password Storage Cheat Sheet on the date in the header. The recommendations are revised as hardware gets faster — re-check them when you update the project and raise ARGON2_OPTIONS: rehashing on login does the rest.

How this was verified3 · passedchecked 12 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.