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 or hkdf is the right cost there.
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
// 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.
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:
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.
const phc = await argon2Hash("pw");
argon2NeedsRehash(phc); // false
argon2NeedsRehash(phc, { m: 65_536, t: 3 }); // truesecret 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.
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 pepperRotating 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.
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.
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 MALFORMEDThe 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 and 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.
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.