Why You Should Never Store Passwords in Plain Text
In October 2013, Adobe announced a data breach. At first they said 2.9 million accounts were affected. Within weeks, that number quietly ballooned to 153 million. Security researchers who analyzed the leaked database found something almost comically bad: Adobe had encrypted passwords using 3DES in ECB mode — with the same key for every password, and no salt. Identical passwords produced identical ciphertext. Researchers didn't even need to crack the encryption; they could look at frequency patterns and deduce that the most common ciphertext corresponded to "123456," the second most common to "123456789," and so on. It was encryption theater. The passwords were, for all practical purposes, stored in plain text.
Adobe is not alone. RockYou, the social app company, suffered a breach in 2009 that exposed 32 million passwords stored in literal, unadorned plain text. No hashing, no encryption, nothing. An attacker with basic SQL injection skills walked out with one of the most valuable wordlists in hacking history — a list that is still used in password cracking to this day and ships with Kali Linux by default. Every time a security tool asks you to test against "rockyou.txt," you are staring at the direct consequence of someone deciding that hashing passwords was too much trouble.
What Actually Happens When You Store Passwords Plainly
Let's be precise about the threat model. When you hash passwords properly, a database breach is painful but survivable. When you store them in plain text — or use reversible encryption that might as well be plain text — the consequences cascade in ways most developers don't fully think through at 2am when they're wiring up a login form.
First, there's the direct account takeover. Every account in the breached database is immediately compromised. No cracking needed, no GPU farm rented, no waiting. The attacker just reads the email column and the password column side by side.
Second, and this is what turns a contained incident into a catastrophe, people reuse passwords. According to a 2023 Google/Harris Poll study, 65% of people use the same password across multiple sites. The attacker who gets your users' plaintext passwords from your e-commerce site doesn't stop there. They take that email-password pair and stuff it into Gmail, PayPal, bank login pages, Amazon. This is called credential stuffing, and it's extraordinarily effective precisely because password reuse is so common. You didn't just lose control of your app. You handed attackers the keys to your users' digital lives.
The 2012 LinkedIn breach illustrates this second-order harm well. LinkedIn stored passwords as unsalted SHA-1 hashes — not plain text, technically, but so close to worthless it doesn't matter. Within days of the 117 million hashed passwords hitting the web, crackers had reversed the majority of them using precomputed rainbow tables. People found their LinkedIn passwords were also their email passwords, their banking passwords, their GitHub passwords. The breach ricocheted across the internet for years afterward, fueling automated attacks on completely unrelated services.
Why "Just Encrypting" Is Not the Answer Either
Some developers, when they hear "don't store plain text," reach for AES encryption. This feels safer but introduces a subtle and serious problem: encryption is reversible. Somewhere in your system — in config files, environment variables, a secrets manager — lives the decryption key. If an attacker gets your database, they probably also get your application server or your deployment configs. If the key is there, encryption buys you almost nothing. Adobe's 3DES fiasco was exactly this failure in practice.
What you actually want is a one-way function. When a user logs in, you don't need to retrieve their password — you just need to verify that what they typed, when transformed the same way, matches what you stored. That's hashing. But not all hashing is equal, and this is where a lot of developers go wrong.
The Salt Problem: Why MD5 and SHA-1 Are Not Password Storage Functions
MD5 and SHA-1 are general-purpose cryptographic hash functions. They are fast. Extremely fast. A modern GPU can compute billions of MD5 hashes per second. This speed is a feature when you're verifying file integrity. It is a catastrophic liability when you're protecting passwords.
The attack is simple. An attacker gets your hashed password database. They fire up hashcat on a cloud GPU instance, feed it a dictionary of common passwords combined with rule-based mutations, and start hashing at billions of guesses per second. Given that most users pick weak passwords, they'll crack a substantial percentage of a database in hours.
Salting — appending a random value unique to each user before hashing — defeats precomputed rainbow tables. An attacker can't look up "5f4dcc3b5aa765d61d8327deb882cf99" in a rainbow table and immediately know it's "password." But salting doesn't fix the speed problem. You've made precomputation useless but per-hash brute force is still very fast.
This is why password hashing needs a fundamentally different tool.
bcrypt: Deliberately, Lovingly Slow
bcrypt was designed in 1999 by Niels Provos and David Mazières specifically for password storage. Its defining feature is a work factor — a tunable cost parameter that controls how computationally expensive the hash is to compute. At work factor 12, bcrypt takes roughly 250–400ms on a modern server. That feels slow for a hash function. It feels exactly right for password verification, because:
- A user logs in once. 300ms is imperceptible.
- An attacker trying to brute-force offline is now limited to a few hundred guesses per second per core, rather than billions.
bcrypt also generates its own salt internally, stores it alongside the hash in the output string, and handles all the encoding. The output — something like $2b$12$EixZaYVK1fsbw1ZfbX3OXe.PkE3/wW8vjnR.I5cJZ3b9... — is self-contained. You store the whole thing in a single database column, and the verification function knows how to parse it.
Practically every major framework ships with bcrypt support. In Node.js it's `bcrypt` or `bcryptjs`. In Python it's `bcrypt` or `passlib`. In PHP, `password_hash()` with `PASSWORD_BCRYPT` has been built-in since PHP 5.5. There is genuinely no excuse.
Argon2: The Current Recommendation
bcrypt is excellent, but it has one limitation: it was designed before multi-gigabyte GPUs were commodity hardware, and it's not memory-hard. An attacker with specialized hardware (FPGAs, GPUs) can parallelize bcrypt attacks more effectively than bcrypt's designers anticipated in 1999.
Argon2 won the Password Hashing Competition in 2015. It was designed to be expensive in both time and memory, which is what makes GPU parallelization difficult. You can configure it to require, say, 64MB of RAM per hash computation. GPUs have fast compute but limited per-thread memory; forcing large memory requirements throttles GPU-based attacks far more aggressively than compute cost alone.
Argon2 comes in three variants. Argon2id is the recommended one for most applications — it provides resistance to both side-channel attacks and GPU attacks. The OWASP Password Storage Cheat Sheet recommends Argon2id with a minimum of 19MB memory cost, an iteration count of 2, and 1 degree of parallelism as a sensible starting baseline, tuned upward based on your server's capability.
Support is growing: Python's `argon2-cffi`, PHP's `password_hash()` with `PASSWORD_ARGON2ID` since 7.3, and libraries for Go, Rust, and Java all exist and are well-maintained.
The Implementation That Actually Matters
There are a few implementation details that turn good intentions into genuine security.
Never write your own hashing logic. Use the library's provided `hash()` and `verify()` functions. Do not concatenate salts manually. Do not roll your own iteration loop. The library handles this correctly; you probably will not.
Use a timing-safe comparison. When verifying passwords, don't use a simple string equality check. Use the library's `verify()` function, which is designed to take constant time regardless of where the comparison fails. This defeats timing attacks where an attacker can infer information from how quickly the server responds.
Plan for rehashing. As hardware gets faster, you'll want to increase your work factor over time. A sensible pattern: when a user successfully logs in (meaning you have their plaintext password for just that moment), check whether the stored hash was generated with your current cost parameters. If not, rehash and update. Users never notice; your security posture quietly improves.
Pair it with 2FA. Even correctly hashed passwords can be obtained through phishing, malware on the user's machine, or the user reusing a password that leaked from another site. Two-factor authentication — TOTP (the kind Google Authenticator uses) being the most practical for most applications — means a leaked password alone is not sufficient to take over an account. It's the layer that makes the residual risk after a hash breach manageable.
The Cost of Getting This Wrong
After Adobe's breach, the company faced class-action lawsuits, a $1.1 million settlement with 15 state attorneys general, and years of reputational damage. RockYou essentially never recovered as a company. LinkedIn faced regulatory scrutiny and lost significant user trust at a critical period in its growth.
None of these consequences were inevitable. bcrypt existed before all of these breaches. The work was not complex. The libraries were available. The decision — whether conscious or through neglect — to not use them cost these companies enormously and cost their users far more.
Storing passwords in plain text, or in anything that is functionally equivalent to plain text, is not a minor oversight. It is a breach of the implicit contract between a service and its users. When someone creates an account with you, they are trusting you with a secret that, due to password reuse, may unlock far more than just your application.
bcrypt and Argon2 exist. They are documented, maintained, integrated into standard libraries, and take approximately ten minutes to implement correctly. Use them.