Store passwords

Hash a password with Argon2 when the account is created, check it at login, and keep the check from telling an attacker which emails have accounts.

You never store the password itself. You store an Argon2 hash of it: a string you can check a later guess against, that's deliberately slow and memory-hungry to compute, and that can't be turned back into the password. Concepts explains why a password needs this treatment when a random token doesn't. The hash comes as a PHC string, $argon2id$v=19$m=…,t=…,p=…$salt$tag. It carries its own salt and cost, so every stored row describes itself, and you can raise the cost later without touching the old ones.

#Before you start

  • Node.js 22.13 or later (24 or later on the 24 line), or any runtime with the Web Crypto API. Argon2 itself is plain JavaScript, so it also runs where WebAssembly cannot.
  • One derivation at the default cost takes about 150 ms and 19 MiB on a 2024 laptop CPU and blocks the thread it runs on. That is the point. Run it where blocking is acceptable, or in a worker thread.
  • Keep the pepper (the secret option) in the environment, not in the database.

#Set up the store and the pepper

The pepper is read once, and the process refuses to start without it. The dummy is a real hash of a value nobody holds, made with the same cost and pepper as every account's. That way the login below does the same work whether or not the email exists.

store-passwords.ts
import { argon2Hash, argon2NeedsRehash, argon2Verify } from "unsecure/argon2";
import { secureRandomBytes } from "unsecure/random";

/** What the application persists per account. */
interface Account {
  id: string;
  passwordHash: string;
}

interface Store {
  findByEmail(email: string): Promise<Account | undefined>;
  setPasswordHash(id: string, hash: string): Promise<void>;
}

// The pepper never sits next to the hashes: a dumped table is useless without it.
const pepper = process.env.PASSWORD_PEPPER;
if (!pepper) throw new Error("PASSWORD_PEPPER is not set");

// A real hash of a value nobody holds, at the same cost as every account's hash,
// so a login for an unknown email takes as long and answers the same `false`.
const absent = await argon2Hash(secureRandomBytes(32), { secret: pepper });

#Register and log in

Registering hashes the password. Logging in checks the guess against the account's hash, or, when there's no such account, against the dummy.

store-passwords.ts
export async function register(store: Store, id: string, password: string): Promise<void> {
  await store.setPasswordHash(id, await argon2Hash(password, { secret: pepper }));
}

export async function login(
  store: Store,
  email: string,
  password: string,
): Promise<Account | null> {
  const account = await store.findByEmail(email);
  const valid = await argon2Verify(account?.passwordHash ?? absent, password, { secret: pepper });
  if (!account || !valid) return null;

  // The plaintext is in hand, so rewrite a hash made with yesterday's cost now.
  if (argon2NeedsRehash(account.passwordHash)) {
    await store.setPasswordHash(account.id, await argon2Hash(password, { secret: pepper }));
  }
  return account;
}
  • Absent accounts: without the dummy, an unknown email would answer in a millisecond and a known one in 150, and your login form would become a list of your users.
  • Accounts without a password: someone who signed up through Google or GitHub has an account and no hash, so passwordHash is null and ?? absent covers them too. Answer with the same "wrong email or password" as for everyone else; "sign in with Google instead" tells the caller the account exists.
  • Dummy and defaults: the dummy is hashed with today's cost, so it matches rows written today. Rows written before you raised the cost run faster until their owner's next login rehashes them, which is the best anyone can do without the password.
  • Rehash on login: argon2NeedsRehash tells you whether a stored hash was made with today's defaults. Raise the defaults, and every account quietly migrates the next time its owner logs in.

#When it fails

SituationWhat happens
Wrong passwordargon2Verify returns false. Nothing to catch.
Unknown email, or an account that only signs in through a providerSame false, after the same work.
Password field is missing or not textargon2Verify throws INVALID_TYPE. Check the body shape first, or map that code to a 400 (see errors).
Stored string is not a PHC string this module wroteThrows MALFORMED, UNSUPPORTED or OUT_OF_RANGE. That is a data bug or an unfinished migration, so it is loud on purpose.
Stored string asks for more memory or time than limits allowThrows OUT_OF_RANGE before any work. The default ceiling is 1 GiB, 64 passes, 64 lanes.
PASSWORD_PEPPER unsetThe module throws at start-up, so a deployment without a pepper never serves a login.

#Options

argon2Hash(password, options) decides the cost; argon2Verify(phc, password, options) reads the cost from the stored string and takes only the last three rows.

OptionDefaultNotes
m19456 (19 MiB)Memory in KiB. The OWASP recommendation for argon2id. Hash only.
t2Passes over that memory. Hash only.
p1Lanes. This implementation runs them one after another, so p > 1 adds cost without parallelism; it exists for interoperability. Hash only.
length32Tag length in bytes. Hash only.
variant"argon2id""argon2i" and "argon2d" exist for interoperability. Hash only.
salt16 random bytesOnly for reproducing a known vector. Leave it alone in an application. Hash only.
secretnoneThe pepper. Same value on hash and verify.
datanoneAssociated data mixed into the hash. Same value on hash and verify.
limits{ m: 1048576, t: 64, p: 64 }Verify only. The highest cost a stored string may ask for.

#Going further

  • Worker threads: on a server, run the derivation in a worker so it doesn't block everyone else. Catch a thrown UnsecureError inside the worker and send it back as a message, or one bad request takes the whole process down.
  • Raw tags: argon2(password, salt, options) gives you the bare tag as hex or bytes, for protocols that define their own storage format.
  • Migrating from bcrypt or PBKDF2: keep the old column for now. When a stored value isn't a PHC string, verify it with the old library, then write an Argon2 hash and clear the old column. Accounts migrate as people log in.
  • Interoperability: the PHC string is the format @node-rs/argon2, the argon2 CLI, argon2-cffi and PHP's password_hash read and write. A hash made with secret verifies only in implementations that accept a pepper, which the CLI and PHP do not.