Store passwords

Argon2 with a pepper, a lazy rehash on login, and the branches that leak if you skip them.

#The column

One text column holds everything: the variant, the version, the cost parameters, the salt and the tag.

$argon2id$v=19$m=19456,t=2,p=1$MDEyMzQ1Njc4OWFiY2RlZg$gy5SuVm5Z7Vw7keB9se9p87QGcomaseB/S2U1OhTsM0

Size it for at least 128 characters. The format is the PHC standard, so a later move to a native binding needs no data migration.

#Registration

import { argon2Hash } from "unsecure/argon2";

export async function register(email: string, password: string) {
  const passwordHash = await argon2Hash(password, { secret: env.PASSWORD_PEPPER });
  await store.createUser({ email, passwordHash });
}

The salt is generated for you, 16 random bytes per call, and stored inside the string. Do not supply your own unless you have a reason.

#The pepper

secret is mixed into the derivation and never written into the stored string. Keep it in the environment, not the database, and a stolen dump alone is not enough to start guessing.

const stored = await argon2Hash(plaintext, { secret: env.PASSWORD_PEPPER });
await argon2Verify(stored, plaintext, { secret: env.PASSWORD_PEPPER }); // true
await argon2Verify(stored, plaintext); // false — same password, no pepper

Rotating the pepper invalidates every hash at once, so treat it as something you re-derive on each user's next login rather than something you change casually.

#Login, with the rehash

import { argon2Hash, argon2NeedsRehash, argon2Verify } from "unsecure/argon2";

const PARAMETERS = { m: 19_456, t: 2, p: 1 } as const;
const ABSENT = "$argon2id$v=19$m=19456,t=2,p=1$c29tZS1maXhlZC1zYWx0$…";

export async function checkPassword(email: string, password: string) {
  const user = await store.findByEmail(email);

  const ok = await argon2Verify(user?.passwordHash ?? ABSENT, password, {
    secret: env.PASSWORD_PEPPER,
  });
  if (!user || !ok) return null;

  if (argon2NeedsRehash(user.passwordHash, PARAMETERS)) {
    await store.setPasswordHash(
      user.id,
      await argon2Hash(password, { ...PARAMETERS, secret: env.PASSWORD_PEPPER }),
    );
  }

  return user;
}

argon2Verify reads its parameters out of the stored string, so an old hash keeps verifying at the cost it was made with. That is what makes lazy rehashing possible: raise PARAMETERS, and each user's hash is rewritten the next time they log in.

argon2NeedsRehash compares the variant, m, t, p, the tag length and the version. It hashes nothing, so it is synchronous and free. Called with no second argument it compares against the same defaults argon2Hash applies.

#Raising the cost

// Before: the library defaults
const PARAMETERS = { m: 19_456, t: 2, p: 1 };

// After: more memory, same passes
const PARAMETERS = { m: 65_536, t: 2, p: 1 };

Deploy the change and the rewrite happens on its own, one login at a time. Users who never come back keep their old hash, verified at its old cost, which is correct: the tag is still the tag.

Lower m before you lower t when you have to go the other way. OWASP's fallbacks trade memory for passes at roughly constant strength: m=12288,t=3, m=9216,t=4, m=7168,t=5.

#The thread

The derivation runs synchronously on the calling thread. At the defaults that is roughly 140 ms during which a server sharing that thread answers nobody.

Warning

On a Node, Bun or Deno server, run the hash in a worker thread and await its message. A CLI, a build step, or a runtime giving each request its own isolate can call it inline.

A minimal worker:

import { parentPort } from "node:worker_threads";
import { argon2Hash, argon2Verify } from "unsecure/argon2";

parentPort!.on("message", async (job) => {
  const result =
    job.kind === "hash"
      ? await argon2Hash(job.password, job.options)
      : await argon2Verify(job.phc, job.password, job.options);
  parentPort!.postMessage({ id: job.id, result });
});

#What throws

Only a wrong password returns false. A stored value in an unexpected format throws, so a migration nobody ran surfaces instead of reading as a wave of failed logins.

await argon2Verify("not-a-phc-string", password); // MALFORMED
await argon2Verify(phc.replace("v=19", "v=16"), password); // UNSUPPORTED
await argon2Verify(phc.slice(0, -2), password); // MALFORMED

Catch those separately from the false:

import { UnsecureError } from "unsecure/errors";

try {
  const ok = await argon2Verify(user.passwordHash, password);
  return ok ? user : null;
} catch (error) {
  if (error instanceof UnsecureError) {
    logger.error({ code: error.code, userId: user.id }, "stored hash is unreadable");
    return null; // the user cannot log in, and you get paged
  }
  throw error;
}

#Migrating from another hash

Keep the old verifier around, and upgrade on the next successful login.

export async function checkPassword(user: User, password: string) {
  if (user.passwordHash.startsWith("$argon2")) {
    const ok = await argon2Verify(user.passwordHash, password);
    if (ok && argon2NeedsRehash(user.passwordHash)) {
      await store.setPasswordHash(user.id, await argon2Hash(password));
    }
    return ok;
  }

  // A legacy bcrypt or PBKDF2 column
  const ok = await legacyVerify(user.passwordHash, password);
  if (ok) await store.setPasswordHash(user.id, await argon2Hash(password));
  return ok;
}