Assume the database leaks
Databases get stolen. If you stored passwords — or fast hashes of them — attackers recover most of them in hours. Store a salted, deliberately slow hash (PBKDF2, bcrypt, scrypt or Argon2): a unique random salt per user defeats precomputed tables, and 100,000 iterations makes every guess expensive.
PBKDF2 with WebCrypto
const toHex = (bytes) => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
const fromHex = (hex) => Uint8Array.from(hex.match(/../g) ?? [], (h) => parseInt(h, 16));
async function pbkdf2(password, salt, iterations) {
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, 256);
return toHex(bits);
}
async function hashPassword(password, { salt = crypto.getRandomValues(new Uint8Array(16)), iterations = 100000 } = {}) {
const saltHex = typeof salt === "string" ? salt : toHex(salt);
const hash = await pbkdf2(password, fromHex(saltHex), iterations);
return `pbkdf2$${iterations}$${saltHex}$${hash}`; // everything needed to check it later
}
async function verifyPassword(password, stored) {
const [scheme, iterations, saltHex, hash] = stored.split("$");
if (scheme !== "pbkdf2") return false;
return safeEqual(await pbkdf2(password, fromHex(saltHex), Number(iterations)), hash);
}