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.
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 withverifyPassword - 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
Know beforehand
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
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=1or equivalent: 46 MiB witht=1, 12 MiB witht=3, 9 MiB witht=4, 7 MiB witht=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
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.
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 files run with the built-in type stripping of 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_OPTIONSis 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: thenneedsRehashcompares 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.verifyreads the parameters and the salt from the hash string and compares the result withtimingSafeEqualfromnode:crypto(that is what the 0.45.1 source does): the comparison time does not depend on how many bytes match.needsRehashcompares 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.
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;
}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
Hash column
Use
textorvarchar(255). An Argon2id string with the recipe parameters is 97 characters long, a bcrypt one is 60; achar(60)column from the bcrypt days will not fit the new hash.Store
Implement
UserStore:findByEmailandupdatePasswordHashare two queries to your table. Keep emails in lower case:authenticatenormalises the input the same way.Sign-up and password change
await hashPassword(password)— and store the whole string.Login
authenticate(store, email, password). Anullresult becomes a single generic message: «invalid email or password».Benchmark on the server
Run the script below on the same configuration as production and pick parameters no lower than the OWASP minimum.
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 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.
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)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
secretoption ofargon2.hashandargon2.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.