How do I store passwords securely?

Direct answer

Hash with argon2id or bcrypt, using the library's default cost and letting it handle the salt. Never use SHA-256, MD5 or anything designed to be fast, speed is the property you don't want. Don't encrypt passwords, because encryption is reversible and hashing isn't, and that's the whole point.

Muhammad HasanUpdated

Password hashing is one of the few areas where the correct answer is short and the wrong answers are all variations on the same mistake.

Use a slow hash

const argon2 = require('argon2');

const hash = await argon2.hash(password);              // registration
const ok = await argon2.verify(hash, attempt);         // login

Or bcrypt, which is older and fine:

const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(attempt, hash);

Both generate a salt automatically and store it inside the hash string. You don't manage it and you shouldn't try.

Why not SHA-256

Because it's fast, and that's a design goal for a general-purpose hash and a defect for a password one. Commodity hardware computes billions of SHA-256 hashes per second, so a leaked database of SHA-256 password hashes is a list of passwords in a few hours.

Argon2 and bcrypt are deliberately slow and memory-hungry. The cost is a few hundred milliseconds per login for you, and the difference between hours and centuries for someone with the database.

Adding a salt to SHA-256 doesn't fix this. Salts prevent precomputed rainbow tables; they don't slow down brute force against a single hash.

Don't encrypt

Encryption is reversible by design, which means a key exists, which means the key can leak alongside the data. Hashing has no key and no reverse. You never need to read a password back. You only need to check whether an attempt matches.

Cost factor

Use the library default and revisit it occasionally. Hardware gets faster, so a cost that was reasonable in 2015 isn't now. Rough target: 200–500ms per hash on your production hardware, tuned by measuring rather than guessing.

The surrounding bits

Don't cap length low. A 72-character bcrypt limit is real; a 16-character limit is a sign the password is being stored somewhere it shouldn't be.

Rate limit login. Slow hashing protects you after a database leak. It does nothing about someone trying common passwords against your live login endpoint. Auth routes without throttling were one of the recurring findings across the 24 AI-generated applications we scanned.

Check against known breaches. Have I Been Pwned's range API lets you check a password against breached sets without sending it, you send the first five characters of the hash and match locally.

Same response for wrong user and wrong password. Different messages tell an attacker which accounts exist.

Better still, don't store them

Passwords you don't hold can't leak. OAuth, passkeys or a managed auth provider removes this problem rather than solving it, and for most products that's the better trade.

If you are storing them, the code above is the whole answer. It's one of the few security problems with a settled solution.

Go deeper

Kolega for AppSec teams

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free