Why You Should Never Store Passwords as MD5 Hashes
For years, MD5 was the go-to algorithm for storing user passwords in databases. If your application was built before 2010 and hasn't been updated, it may still be doing this. Here's why that's a serious security problem — and what the correct approach looks like.
A brief history of MD5 password storage
MD5 was designed in 1991 by Ron Rivest and was widely adopted throughout the 1990s and 2000s. Early web applications stored passwords by running the plaintext through MD5 and saving the resulting 32-character hex string. The logic seemed sound: MD5 is a one-way function, so even if an attacker stole your database, they couldn't reverse the hashes back to passwords.
The flaw in this reasoning is that reversing a hash is not the only way to recover a password. As long as you know the algorithm and the output, you can precompute the hash of every possible password and look up the answer in a table — no reversal required.
Rainbow table attacks explained
A rainbow table is a precomputed lookup table that maps hash values back to the input strings that produced them. Instead of brute-forcing passwords on the fly, an attacker downloads a table (often tens of gigabytes) that already has the MD5 hash of every common password, every word in multiple languages, every numeric string up to a certain length, and millions of known password patterns.
Once an attacker has your MD5-hashed password database, cracking it is as simple as a table lookup:
# Example: MD5 of the password "password"
echo -n "password" | md5sum
# Output: 5f4dcc3b5aa765d61d8327deb882cf99
# This hash appears in every rainbow table ever published.
# Cracking time: milliseconds.MD5 was also designed to be fast — it can compute billions of hashes per second on modern GPU hardware. This is a desirable property for checksum verification but catastrophic for password storage, where you want the opposite: a hash that is intentionally slow.
What about salting?
Adding a random salt— a unique string appended to each password before hashing — defeats precomputed rainbow tables because the attacker's table was built without your specific salt. The attacker must now brute-force each password individually.
# Salted MD5 (still not good enough)
md5(salt + password)
# e.g., md5("a8f3k2" + "password")
# This defeats rainbow tables, but MD5 is still too fast.
# A GPU can test ~10 billion salted MD5s per second.Even with salting, MD5 remains too fast. Modern password cracking rigs can test tens of billions of MD5 hashes per second. A 8-character mixed-case alphanumeric password has about 218 trillion combinations — attackable in under an hour on serious hardware. Using MD5, even with a salt, provides inadequate protection against a determined attacker with a stolen database.
What bcrypt, scrypt, and Argon2 do differently
Password hashing functions like bcrypt, scrypt, and Argon2 were specifically designed to be slow and resource-intensive. They have tunable cost factors that let you increase the computational work required as hardware gets faster.
- bcrypt — Designed in 1999, still widely used. Has a configurable cost factor (typically 10–14 for modern applications). At cost 12, bcrypt takes ~250ms per hash — fast enough for login but slow enough to make brute-forcing extremely expensive. Limit: maximum password length of 72 bytes.
- scrypt — Memory-hard, meaning it requires significant RAM in addition to CPU time. This makes GPU-based attacks less effective since GPUs have limited high-speed memory. Used by many password managers and cryptocurrencies.
- Argon2 — The winner of the 2015 Password Hashing Competition. The modern recommendation. Three variants: Argon2d (GPU-resistant), Argon2i (side-channel resistant), Argon2id (hybrid, recommended). Configurable time, memory, and parallelism parameters.
// Node.js example using bcrypt
const bcrypt = require('bcrypt');
const saltRounds = 12;
// Hashing (at login/signup)
const hash = await bcrypt.hash('user_password', saltRounds);
// Takes ~250ms. Hash includes the salt automatically.
// Verifying (at login)
const match = await bcrypt.compare('user_password', hash);
// Returns true if the password matches.The practical takeaway for developers
If you are building a new application, use Argon2id if your language/framework supports it (Python: argon2-cffi, Node.js: argon2, PHP 7.2+: password_hash($pass, PASSWORD_ARGON2ID)). Use bcrypt as a safe, battle-tested fallback if Argon2 is not available.
If you have an existing application storing MD5 or SHA-1 password hashes, the upgrade path is:
- On the next successful login, re-hash the verified plaintext password with bcrypt/Argon2 and store the new hash.
- After a migration period, prompt or require remaining users with old-format hashes to reset their passwords.
MD5 and SHA-1 have their legitimate uses — file checksums, cache keys, non-security identifiers — but password storage is not one of them, and hasn't been for well over a decade.