Password hashing
Think of it like… a paper shredder that always cross-cuts to the same government spec. You do not choose the blade pattern, the strip width, or how many passes it makes: you feed a password in, and the only thing that comes out is confetti that can be compared against other confetti, never reassembled.
Daloy ships exactly one correct way to hash a password. The API is two functions with no knobs: no algorithm switch, no cost-factor argument, no salt management. Import them from the @daloyjs/core/hashing subpath:
- 01inputPlaintext passwordUTF-8, 1 to 4096 bytes
- 02saltRandom 16-byte saltcrypto.randomBytes per call
- 03kdfscrypt (Node core)N=2^17, r=8, p=1, 32-byte key
- 04outputPHC-style string$scrypt$N=131072,r=8,p=1$…$…
Why scrypt, not Argon2 or bcrypt
OWASP's Password Storage Cheat Sheet lists Argon2id first and scrypt as the recommended alternative. Argon2id has no implementation in Node core: using it means installing a native binding, and @daloyjs/core refuses to ship runtime dependencies as a supply-chain guarantee. scrypt is memory-hard like Argon2, ships in node:crypto, and Daloy pins it to the OWASP-aligned parameters (N = 2^17, r = 8, p = 1, 32-byte key, 16-byte salt). bcrypt is not memory-hard and silently truncates passwords at 72 bytes, so it is not offered at all.
If your organization mandates Argon2id specifically, install the argon2 package in your app and use it directly: the framework intentionally does not wrap it.
Constant-time verification, no exception oracle
passwordVerify() re-derives the key with the parameters stored in the PHC string and compares digests with crypto.timingSafeEqual. It returns false for anyfailure, including a malformed or truncated stored hash, and never throws. A caller (or an attacker watching your error responses) cannot distinguish "corrupt hash in the database" from "wrong password" through exception side channels.
The PHC string
The output is a PHC-style string that is safe to store in a single database column:
Verification accepts only this exact shape. Hashes that claim different N/r/p values are rejected rather than honored, so an attacker who can tamper with stored hashes cannot downgrade a record to a cheap cost factor and brute-force it offline.
Input guardrails
- Empty passwords are rejected:
passwordHashthrows aTypeErrorandpasswordVerifyreturnsfalse. - 4096-byte cap: scrypt runs PBKDF2-HMAC-SHA256 over the full password, so an unbounded input lets an attacker amplify CPU per call. Anything longer than 4096 UTF-8 bytes is refused, which is far above any legitimate passphrase.
- Fresh salt per hash: hashing the same password twice yields two different strings, so equal passwords are not linkable across rows.
In a login slice
Pair it with loginThrottle() (see the login throttle page) so online guessing is rate-limited, and with the auth slice for the full session/CSRF picture. The DUMMY_HASH pattern above keeps the scrypt work constant whether or not the account exists; generate it once at boot with await passwordHash(randomUUID()).
When not to use it
passwordHash is for credentials a human types. For API keys and webhook secrets that are long and random, a plain SHA-256 digest compared with timingSafeEqual() is appropriate and thousands of times cheaper: memory-hard KDFs only exist to slow down guessing of low-entropy secrets. For signing, see HTTP message signatures.