Hashes, passwords, and MACs
Understand salts and work factors
Use unique salts generated by the password library and tune cost parameters so identical passwords do not share a verifier and guesses remain expensive.
A salt is a random value mixed into each password hash. It is unique per password, and it does not need to be secret. Its job is to stop precomputed and shared work.
Picture two users who pick the same password. With a shared salt, or no salt at all, their stored verifiers match. Crack one and you get the other for free. Worse, the attacker can crack the whole table at once, testing each guess against every row. Or they use rainbow tables built years before your breach.
With unique salts, every row becomes a separate cracking job. Same password, different verifier:
import argon2 from 'argon2'
await argon2.hash('hunter2')
// $argon2id$v=19$m=65536,t=3,p=4$Rk9Qb2ZYc1E$...
await argon2.hash('hunter2')
// $argon2id$v=19$m=65536,t=3,p=4$x1VtZ2JkQnc$... <- different salt, different result
Let the library handle the salt
The library generates the salt and stores it inside the encoded result. Look at that string. Separated by $ you have the algorithm (argon2id), the version, the cost parameters (m, t, p), the salt, and the hash. Verification reads everything it needs from the string itself.
This is why you should never build a salt column by hand or invent your own format. Hand-rolled salting is where the bugs live: reused salts, truncated salts, salts applied to the wrong input. The library already solved this. Let it.
Work factors age
The work factor is the cost knob. For Argon2id it is memory and iterations. For bcrypt it is rounds. You tune it to your hardware, then raise it as hardware gets faster. A cost that felt expensive five years ago is cheap on today’s GPUs.
Be careful with what a salt does and does not do. Unique salts stop shared precomputation. They do not make weak passwords strong. A user whose password is 123456 falls in the first seconds of any attack, whatever your parameters.
Old verifiers do not upgrade themselves. The moment to rehash is right after a successful login, because that is when you hold the plaintext legitimately. Check whether the stored verifier uses an older policy, and if so write a new one at the current cost:
if (await argon2.verify(stored, password) && argon2.needsRehash(stored, currentOptions)) {
await saveVerifier(userId, await argon2.hash(password, currentOptions))
}
Users who never log in again keep their old verifier. That is fine. Their accounts are also the ones you can force through a password reset when the old cost gets too weak.
Try this on your own: hash the same password twice through the library and compare the two encoded results. Verify both, then find where the algorithm, salt, and work factors sit in the string. Lower the cost for one test verifier and confirm that a successful login upgrades it, without ever storing the plaintext.
Lesson completed