
# 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

```ts
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.

```ts
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

```ts
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

```ts
// 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:

::code-group

```ts [worker.ts]
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 });
});
```

```ts [pool.ts]
import { Worker } from "node:worker_threads";

const worker = new Worker(new URL("./worker.ts", import.meta.url));
const pending = new Map<number, (value: unknown) => void>();
let nextId = 0;

worker.on("message", ({ id, result }) => {
  pending.get(id)?.(result);
  pending.delete(id);
});

export function run(job: Record<string, unknown>) {
  const id = nextId++;
  return new Promise((resolve) => {
    pending.set(id, resolve);
    worker.postMessage({ ...job, id });
  });
}
```

::

## 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.

```ts
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`:

```ts
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.

```ts
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;
}
```
