
# Password hashing

> Argon2 (RFC 9106) in plain JavaScript, with a PHC string that any conforming implementation can read.

::note
**ELI5.** A password is short and guessable, so you must never store it, and you must not store a fast fingerprint of it either: an attacker with your database and a graphics card tries billions of guesses a second. Argon2 is a deliberately slow, deliberately memory-hungry one-way function. It takes about a tenth of a second and 19 MB of memory per attempt, which is nothing for one login and ruinous for a billion guesses. Use it for anything a human typed. Do not use it for random API tokens, session ids or shared secrets: those have enough entropy already, and [`hash`](/crypto/hashing/hash) or [`hkdf`](/crypto/key-derivation) is the right cost there.
::

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

// Registration
user.passwordHash = await argon2Hash(plaintext);

// Login
if (!(await argon2Verify(user.passwordHash, submitted))) return unauthorized();
```

No WebAssembly, no native binding, no Node built-ins. The module carries its own BLAKE2b (RFC 7693) and does 64-bit arithmetic as pairs of 32-bit halves in a `Uint32Array`, so it runs anywhere the rest of the library does.

## Signatures

```ts
// The pair you usually want
function argon2Hash(
  password: string | BytesSource,
  options?: Argon2Parameters & { salt?: string | BytesSource },
): Promise<string>; // a PHC string

function argon2Verify(
  phc: string,
  password: string | BytesSource,
  options?: { secret?: string | BytesSource; data?: string | BytesSource },
): Promise<boolean>;

// Synchronous, hashes nothing
function argon2NeedsRehash(phc: string, parameters?: Argon2Parameters): boolean;

// The raw KDF underneath
function argon2(
  password: string | BytesSource,
  salt: string | BytesSource,
  options?: Argon2Parameters & { returnAs?: DigestReturnAs },
): Promise<string | Uint8Array>;

interface Argon2Parameters {
  variant?: "argon2id" | "argon2i" | "argon2d";
  m?: number;
  t?: number;
  p?: number;
  length?: number;
  secret?: string | BytesSource;
  data?: string | BytesSource;
}
```

## Parameters

| Option    | Type                                   | Default         | Range         | What it does                                                    |
| --------- | -------------------------------------- | --------------- | ------------- | --------------------------------------------------------------- |
| `variant` | `"argon2id" \| "argon2i" \| "argon2d"` | `"argon2id"`    | —             | `argon2id` is the one to use unless a spec says otherwise.      |
| `m`       | `number`                               | `19456`         | `8p … 2^32-1` | Memory in KiB. 19456 is 19 MiB.                                 |
| `t`       | `number`                               | `2`             | `1 … 2^32-1`  | Passes over that memory.                                        |
| `p`       | `number`                               | `1`             | `1 … 2^24-1`  | Lanes. See the pitfall below before raising it.                 |
| `length`  | `number`                               | `32`            | `>= 4`        | Tag length in bytes.                                            |
| `salt`    | `string \| BytesSource`                | 16 random bytes | `>= 8` bytes  | `argon2Hash` generates one for you and stores it in the string. |
| `secret`  | `string \| BytesSource`                | none            | —             | A pepper. Mixed in, never written into the PHC string.          |
| `data`    | `string \| BytesSource`                | none            | —             | Associated data. Also never stored.                             |

The defaults are OWASP's argon2id recommendation.

## The stored format

`argon2Hash` returns a PHC string, and that string is the entire storage format. Salt and parameters travel inside it, so one text column is all you need.

```
$argon2id$v=19$m=19456,t=2,p=1$MDEyMzQ1Njc4OWFiY2RlZg$gy5SuVm5Z7Vw7keB9se9p87QGcomaseB/S2U1OhTsM0
 └ variant └ ver └ parameters   └ salt (b64)           └ tag (b64)
```

Standard base64, no padding. This is the same string the reference `argon2` CLI, `@node-rs/argon2`, `argon2-cffi` and PHP's `password_hash` produce and accept, so you can replace the implementation without touching the column.

`argon2Verify` reads its parameters out of the stored string, never from the current defaults. An old hash keeps verifying at the cost it was made with.

## Raising the cost later

Raising a default only helps once the old rows are rewritten. `argon2NeedsRehash` is what tells you which ones.

```ts
const ok = await argon2Verify(user.passwordHash, submitted);
if (ok && argon2NeedsRehash(user.passwordHash)) {
  user.passwordHash = await argon2Hash(submitted); // rehash at today's parameters
  await db.users.update(user);
}
```

With no second argument it compares against the same defaults `argon2Hash` applies, so raising a default is the whole migration. Pass the parameters explicitly when you hash with your own:

```ts
const PARAMETERS = { m: 65_536, t: 3 };

const ok = await argon2Verify(user.passwordHash, submitted, { secret: env.PEPPER });
if (ok && argon2NeedsRehash(user.passwordHash, PARAMETERS)) {
  user.passwordHash = await argon2Hash(submitted, { ...PARAMETERS, secret: env.PEPPER });
}
```

It compares the variant, `m`, `t`, `p` and the tag length, and answers `true` for any version other than `0x13`. It hashes nothing, so it is synchronous and free.

```ts
const phc = await argon2Hash("pw");
argon2NeedsRehash(phc); // false
argon2NeedsRehash(phc, { m: 65_536, t: 3 }); // true
```

`secret` and `data` never travel in the string and are ignored here. The plaintext is only available inside a successful login, which is why the rehash belongs there and nowhere else.

## Using a pepper

`secret` is mixed into the derivation but 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 });
// "$argon2id$v=19$m=19456,t=2,p=1$…$…" — the pepper is not in there

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

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

## Raw key material from a passphrase

`argon2()` is the KDF with nothing wrapped around it: you supply the salt, you get the tag.

```ts
import { argon2 } from "unsecure/argon2";

const key = await argon2(passphrase, salt, {
  m: 65_536,
  t: 3,
  length: 64,
  returnAs: "uint8array",
});
```

Store the salt yourself. There is no PHC string here to carry it.

## Errors

| Cause                                                                                  | `code`         |
| -------------------------------------------------------------------------------------- | -------------- |
| `password`, `salt`, `secret` or `data` is neither text nor bytes                       | `INVALID_TYPE` |
| `phc` is not a string                                                                  | `INVALID_TYPE` |
| A cost parameter, tag length or salt length outside its range                          | `OUT_OF_RANGE` |
| `phc` is a string but not a well-formed PHC string, or a field is not canonical base64 | `MALFORMED`    |
| An unknown variant, an unsupported version, an unknown `returnAs`                      | `UNSUPPORTED`  |

Bounds: `p` 1 to 2^24-1, `m` 8p to 2^32-1, `t` 1 to 2^32-1, `length` at least 4, a supplied salt at least 8 bytes.

## Pitfall: expecting `false` for a malformed hash

Only a wrong password returns `false`. A stored value in an unexpected format is a bug or a migration nobody ran, and reporting it as "wrong password" would bury it.

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

The salt and tag are decoded strictly. A field one character past a whole group, or with bits set beyond its last byte, is `MALFORMED` with the codec's own error as `cause`. A corrupted column is a corrupted column, not a failed login.

## Pitfall: expecting `await` to yield

All three functions are `async` for symmetry with [`hash`](/crypto/hashing/hash) and [`hmac`](/crypto/hashing/hmac), but the derivation runs synchronously on the calling thread. The promise settles only after every block has been computed, and nothing else on that thread runs in the meantime.

At the defaults that is roughly 140 ms per call, during which a server sharing the thread answers nobody.

::warning
Where logins share a thread with other requests — 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 that gives each request its own isolate can call it inline.
::

## Pitfall: raising `p` to go faster

`p` is a parameter of the function, not a threading hint. Lanes are computed sequentially here, because there is no portable shared-memory threading to use, so raising `p` changes the tag without making anything faster. Leave it at `1` unless you have to match tags produced by a parallel implementation.

## Pitfall: timing the absent-account path

`argon2Verify` is constant-time in the tag comparison, but skipping it when no account matches leaks which addresses exist. Verify against a fixed throwaway string in that branch so both paths cost the same hash.

```ts
const ABSENT = "$argon2id$v=19$m=19456,t=2,p=1$…$…"; // a hash of a value nobody holds
const ok = await argon2Verify(user?.passwordHash ?? ABSENT, submitted);
```

## Note on cost

Pure JavaScript Argon2 is roughly an order of magnitude slower than a native binding: on a laptop, about 155 ms per hash at the defaults against about 13 ms for `@node-rs/argon2`, and on par with `@noble/hashes`.

That is a real cost per login, and it is also what makes the module usable where WebAssembly cannot be compiled from bytes at request time, which is how most Wasm Argon2 packages load. Check the platform's CPU budget before relying on it: a per-request quota of a few milliseconds ends the hash before it finishes.

Lower `m` before you lower `t`. OWASP's fallbacks trade memory for passes at roughly constant strength: `m=12288,t=3`, `m=9216,t=4`, `m=7168,t=5`.
