Skip to main content
Back to module
Back-End Web APIs · Auth and Security

Password hashing

Reading18mLesson 2 of 3 in this module

Explain why passwords are stored as salted, slow hashes and how to do it with a maintained library.

Why it matters

Databases get leaked: through a SQL injection bug, a misconfigured backup, a stolen laptop or an insider. When that happens, the question is whether the attacker also gets every user's password. People reuse passwords, so a leaked password from your site can unlock their email or bank. Storing passwords correctly means that even with a full copy of your database, an attacker has to do enormous work to recover them. It is a small amount of code with a very large effect.

Encryption is the wrong tool

Encryption is reversible: anyone with the key gets the original back. If the key sits on the same server as the database (it usually does), a leak of one is likely a leak of both. You never need to read a user's password back, only to check that a login attempt matches. That is a job for a hash.

Hashing

A cryptographic hash function turns any input into a fixed-size output (a digest). It is one-way (you cannot compute the input from the digest) and the same input always gives the same digest:

sha256("hunter2") -> f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7

At login, you hash the attempt and compare digests. So far so good, but plain fast hashes such as MD5 or SHA-256 are not enough for passwords, for two reasons.

1. Same password, same hash. If a thousand users chose password123, they all have the same digest. An attacker cracks one and gets a thousand accounts. Worse, attackers keep huge precomputed tables of digests for common passwords (rainbow tables), so a plain SHA-256 of a common password can be looked up instantly.

2. Fast is bad here. SHA-256 is designed to be fast. A single modern graphics card can compute billions of SHA-256 hashes per second, so guessing every likely password takes very little time.

Salt: make every hash unique

A salt is a random value, generated fresh for each user, that is combined with the password before hashing:

user  salt              stored hash
ada   9f1c...e2 (random) hash("9f1c...e2" + "password123") -> 4b7a...
bob   03aa...71 (random) hash("03aa...71" + "password123") -> d91e...

Now two users with the same password have different hashes, and precomputed tables are useless because the attacker would need a table per salt. The salt is not secret; it is stored next to the hash. Its job is uniqueness, not secrecy.

Slow, adaptive hashing

The second fix is to use a hash designed to be deliberately slow and tunable, so that one check takes tens to hundreds of milliseconds. A user logging in does not notice; an attacker trying billions of guesses does. These are called password hashing functions or key derivation functions:

AlgorithmNotes
Argon2idCurrent first choice in the OWASP Password Storage Cheat Sheet; memory-hard, which makes GPU attacks costly
scryptMemory-hard; built into Node as crypto.scrypt
bcryptOlder, still widely used and acceptable; has a work factor ("cost") and a 72-byte input limit
PBKDF2Acceptable with a high iteration count; required by some compliance rules

Each has a cost parameter. Raise it over the years as hardware gets faster. Good libraries generate the salt for you and store the algorithm, cost and salt inside the output string, for example $2b$12$... for bcrypt, so you can upgrade later.

Worked example in Node

With the bcrypt package (or bcryptjs, a pure JavaScript version):

import bcrypt from "bcrypt";

// Signup: hash once, store only the hash.
const passwordHash = await bcrypt.hash(password, 12); // 12 = cost factor
await db.users.insert({ email, passwordHash });

// Login: compare the attempt with the stored hash.
const user = await db.users.findByEmail(email);
const ok = user ? await bcrypt.compare(attempt, user.passwordHash) : false;
if (!ok) return res.status(401).json({ error: "Invalid email or password." });

Notice that compare reads the salt and cost from the stored string, so you never handle the salt yourself. Notice also the single error message: the response does not say whether the email exists. Careful implementations even run a dummy compare when the email is unknown, so the response time does not give it away either.

Around the hash

Hashing is one layer. The rest of a sound password system:

  • Rate-limit and lock out repeated failed logins, so online guessing is slow.
  • Set a sensible minimum length (NIST guidance suggests at least 8, preferably more) and allow long passphrases. Check new passwords against lists of known-breached passwords instead of forcing odd character rules.
  • Offer two-factor authentication, so a stolen password alone is not enough.
  • Never log passwords, including in request-body logs and error reports.
  • A pepper (an extra secret key kept outside the database, for example in an environment variable) adds protection if only the database leaks. It is optional and must be managed like any other secret.

Common mistakes

  • Storing passwords in plain text or with reversible encryption.
  • Using a fast hash (MD5, SHA-1, SHA-256) without a password hashing function.
  • One salt for every user, or a salt derived from the username.
  • Writing your own scheme instead of using a maintained library.
  • Different error messages for "unknown email" and "wrong password".

Quick recap

  • Never store passwords or encrypt them; store a salted, slow hash.
  • A salt is random per user and makes identical passwords hash differently. It is stored, not secret.
  • Use Argon2id, scrypt or bcrypt with a cost you raise over time.
  • Combine hashing with rate limits, breached-password checks, 2FA and clean logs.

The Do tab quiz checks that you can explain each piece in your own words.

Also in this lesson: