How to Hash Passwords Properly in Node.js
Password hashing is the process of turning a user's password into a slow, salted verifier that can be stored without keeping the password itself. In Node.js, the practical answer is simple: use Argon2id when you can, bcrypt when you need broad compatibility, or crypto.scrypt when you want a built-in option with no native package dependency.
The mistake is using crypto.createHash("sha256") for login passwords. SHA-256 is useful for checksums, signatures, and content fingerprints, but it is designed to be fast. Password storage needs the opposite: a deliberately expensive password hashing function with a unique salt, tunable cost, safe verification, and a migration path for stronger settings later.
What should Node.js use to hash passwords?
Node.js should use Argon2id, bcrypt, or scrypt for password hashing. Argon2id is the best modern default because it is memory-hard and balances resistance to GPU cracking with safer behavior across common attack models; bcrypt is still a defensible choice when your stack already depends on it; scrypt is useful because Node ships it in the built-in node:crypto module.
Here is the decision I would use for a normal Node.js web app in 2026:
| Situation | Use | Why |
|---|---|---|
| New app, native dependency is acceptable | Argon2id | OWASP recommends Argon2id and it is memory-hard |
| Existing app already uses bcrypt | bcrypt | Keep it, raise cost over time, migrate during logins |
| App where native packages are a deployment problem | crypto.scrypt |
Built into Node and still designed for password-derived keys |
| Password reset tokens or API signatures | HMAC-SHA-256 | Different problem: token integrity, not password storage |
| File checksums or quick fingerprints | SHA-256 | Good for integrity checks, wrong for login password storage |
If you want to see how SHA-256, SHA-384, and SHA-512 digests differ, the Hash Generator is useful for quick inspection. Just do not use a raw SHA digest as the value in your users.password_hash column.
For test accounts and fixtures, use a real random value from the Password Generator instead of memorable demo passwords like Password123!.
Why is SHA-256 wrong for passwords?
SHA-256 is wrong for passwords because it is too fast. Attackers do not reverse password hashes; they guess likely passwords, hash each guess, and compare the result against a leaked database. A fast general-purpose hash helps the attacker test more guesses per second.
This is the same distinction covered in SHA-256 vs MD5 vs SHA-1: cryptographic hash functions are useful primitives, but passwords need a password hashing scheme. NIST SP 800-63B says stored passwords should be salted and hashed with a suitable password hashing scheme, and the cost factor should be as high as practical without hurting verifier performance.
Here is the anti-pattern:
import { createHash } from "node:crypto";
function hashPasswordBad(password) {
return createHash("sha256").update(password, "utf8").digest("hex");
}
That code is deterministic, unsalted, and fast. If two users choose the same password, they get the same hash. If an attacker gets the database, commodity cracking tools can chew through common passwords quickly.
Adding a salt to SHA-256 helps with duplicate hashes and rainbow tables, but it does not solve the speed problem. Password hashing needs a cost knob.
How do you hash passwords with Argon2id in Node.js?
Argon2id hashes passwords in Node.js with a library such as argon2, a per-password random salt handled by the library, and parameters for memory cost, time cost, and parallelism. The stored hash should include the algorithm and parameters so you can verify it later and migrate settings over time.
Install the package:
npm install argon2
Use it like this:
import argon2 from "argon2";
export async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
}
export async function verifyPassword(password, storedHash) {
return argon2.verify(storedHash, password);
}
The returned string is not just a digest. It is an encoded verifier that includes the Argon2 variant, version, memory cost, time cost, parallelism, salt, and hash. It looks roughly like this:
$argon2id$v=19$m=19456,t=2,p=1$...
That format matters. If you raise memoryCost later, old hashes can still verify because their original settings are stored with the hash. On successful login, you can check whether the hash needs rehashing and update it with newer parameters.
How do you hash passwords with bcrypt in Node.js?
bcrypt hashes passwords in Node.js with a cost factor, usually called rounds. The higher the cost, the slower each guess becomes. A cost of 12 is a common starting point for many production apps, but you should benchmark it on your own hardware and pick the highest value that keeps login latency acceptable.
Install bcrypt:
npm install bcrypt
Then hash and verify:
import bcrypt from "bcrypt";
const BCRYPT_COST = 12;
export async function hashPassword(password) {
return bcrypt.hash(password, BCRYPT_COST);
}
export async function verifyPassword(password, storedHash) {
return bcrypt.compare(password, storedHash);
}
bcrypt is older than Argon2id, and it has a practical limitation: many implementations only use the first 72 bytes of a password. That can surprise teams that accept long passphrases, pasted password-manager values, or generated secrets with non-ASCII characters. If you are building a new system and have the choice, I prefer Argon2id.
Still, bcrypt is not obsolete in the way MD5 and SHA-1 are. It is widely reviewed, widely deployed, and supported by mature Node packages. For many existing apps, the right move is to keep bcrypt and improve cost, monitoring, and migration, not rewrite auth during a deadline.
How do you hash passwords with Node's built-in scrypt?
scrypt derives password verifiers with a memory-hard key derivation function available through node:crypto. It is a good option when you want password hashing without adding argon2 or bcrypt as a dependency.
Here is a compact implementation:
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
const scryptAsync = promisify(scrypt);
export async function hashPassword(password) {
const salt = randomBytes(16).toString("hex");
const key = await scryptAsync(password, salt, 64, {
N: 16384,
r: 8,
p: 5,
});
return `scrypt$N=16384,r=8,p=5$${salt}$${key.toString("hex")}`;
}
export async function verifyPassword(password, storedHash) {
const [, params, salt, keyHex] = storedHash.split("$");
const options = Object.fromEntries(
params.split(",").map((pair) => {
const [key, value] = pair.split("=");
return [key, Number(value)];
}),
);
const expected = Buffer.from(keyHex, "hex");
const key = await scryptAsync(password, salt, expected.length, options);
return expected.length === key.length && timingSafeEqual(expected, key);
}
Node's crypto.scrypt takes a password, salt, key length, and cost options. Node's crypto.timingSafeEqual compares byte values with a constant-time algorithm, but the surrounding code still needs care. The length check above prevents timingSafeEqual from throwing on mismatched buffers.
The important storage pattern is the same as Argon2id and bcrypt: store the algorithm, parameters, salt, and derived key together. Do not store the raw password. Keep the verifier format easy to parse so future migrations do not depend on tribal knowledge.
What should a password hash record store?
Password hash records should store enough information to verify the password and migrate the hash later. At minimum, store the algorithm, cost parameters, salt, and hash output. If you use Argon2id or bcrypt, the standard encoded hash string already does most of this for you.
A user table often ends up with fields like:
create table users (
id uuid primary key,
email text not null unique,
password_hash text not null,
password_updated_at timestamptz not null default now()
);
For a real app, I also want:
- A
password_hashformat that includes algorithm metadata. - A login path that can rehash old passwords after successful verification.
- Rate limiting around login attempts.
- Multi-factor authentication for high-risk accounts.
- A password reset flow that stores only hashed, expiring reset tokens.
That last point is easy to mix up. Reset tokens are not login passwords. For tokens, generate a high-entropy random value with crypto.randomBytes, send the raw token once, and store only a keyed or hashed representation with an expiration time.
Should you use a pepper?
Pepper is an application-level secret added to the password hashing process and stored separately from the database. If the database leaks but the app secret does not, the pepper can make offline cracking much harder.
The trade-off is operational. A pepper must live in a secret manager, not in the same database row as the password hash. Rotation is harder than rotating an API key because existing password hashes depend on the old pepper. If you use one, version it and plan how login-time migration will work.
A simple pattern is HMAC-before-hash:
import { createHmac } from "node:crypto";
import argon2 from "argon2";
function applyPepper(password) {
return createHmac("sha256", process.env.PASSWORD_PEPPER)
.update(password, "utf8")
.digest("hex");
}
export async function hashPassword(password) {
return argon2.hash(applyPepper(password), {
type: argon2.argon2id,
});
}
Use this only if you can protect and rotate the pepper properly. A badly managed pepper can lock users out or turn into another production secret that nobody understands.
What are the common password hashing mistakes?
Password hashing mistakes usually come from treating password storage like ordinary hashing. These are the ones I see most often in Node.js apps:
- Using
crypto.createHash("sha256")instead of Argon2id, bcrypt, or scrypt. - Reusing one global salt for every user.
- Choosing cost settings once and never revisiting them.
- Comparing hashes with
===in security-sensitive verification code. - Logging raw passwords during debugging.
- Storing password reset tokens as plaintext.
- Blocking Node's event loop with synchronous hashing on login.
- Forgetting rate limits because the hash is "slow enough."
The event loop point matters. Prefer async argon2.hash, bcrypt.hash, and crypto.scrypt in request handlers. Synchronous password hashing can pin a Node worker at exactly the moment login traffic spikes.
Quick reference
| Question | Short answer |
|---|---|
| Best new Node.js default | Argon2id |
| Best built-in Node option | crypto.scrypt |
| Still acceptable for existing apps | bcrypt with a benchmarked cost |
| Wrong for password storage | Raw SHA-256, SHA-512, MD5, SHA-1 |
| Salt rule | Unique random salt per password |
| Pepper rule | Optional; store in a secret manager, not the database |
| Verify rule | Use the library verifier or constant-time comparison |
| Migration rule | Store algorithm and parameters with the hash |
| Tooling note | Use the Hash Generator to inspect digests, not to store login passwords |
Use Argon2id for new Node.js authentication unless you have a good reason not to. If you are auditing an existing system, search for createHash("sha256"), createHash("md5"), and password_hash first. Those strings usually tell you whether the app is using a real password hashing scheme or just hoping a fast digest is enough.
Sources
- OWASP, Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- NIST, SP 800-63B password verifier guidance: https://pages.nist.gov/800-63-4/sp800-63b.html
- Node.js crypto documentation for
scryptandtimingSafeEqual: https://nodejs.org/api/crypto.html - IETF, RFC 9106: Argon2 memory-hard function: https://www.rfc-editor.org/rfc/rfc9106.html
Try the tool mentioned in this article:
Open Tool →