Password Security: Generation, Hashing, and Storage Best Practices
In backend engineering, few responsibilities matter more than protecting user credentials. A CSS bug or a slow query causes a temporary problem; a password breach destroys user trust, attracts GDPR fines, and can sink a business.
Security here is a moving target. A decade ago, MD5 and SHA-1 were acceptable fast hashes. Today a consumer GPU can crack billions of MD5 hashes per second, so passwords that were secure in 2015 fall in minutes now.
This guide covers the full lifecycle of a password: the math of entropy, why cryptographic randomness is required for token generation, how the hashing algorithms compare (bcrypt vs Argon2id vs PBKDF2), and the current NIST guidelines that overturn the old corporate password rules.
Stop writing insecure token generators. Generate cryptographically random passwords and API keys with our local Secure Password Generator, or test your hashing in the browser with the Bcrypt Generator.
1. Password Entropy
When people argue about whether a password policy should require a capital letter or a special symbol (!@#), they’re missing the concept that actually governs security: information entropy.
Entropy measures unpredictability. For passwords, it’s how many binary decisions (bits) a computer has to work through to brute-force the string. Higher entropy means exponentially more work for the attacker.
The entropy formula
Entropy = log₂(N^L)
Where:
- N is the pool of possible characters (e.g., 26 for lowercase English letters).
- L is the length of the password.
Entropy by construction
| Password type | Pool (N) | Length (L) | Entropy (bits) | Time to crack (high-end GPU array) |
|---|---|---|---|---|
| Lowercase only | 26 | 8 chars | 37.6 bits | < 1 second |
| Mixed case + numbers | 62 | 8 chars | 47.6 bits | ~5 minutes |
| All printable ASCII | 94 | 12 chars | 78.8 bits | ~3,000 years |
| All printable ASCII | 94 | 16 chars | 105.1 bits | Effectively unbreakable |
| Six random words (passphrase) | 7776 | ~30 chars | 77.5 bits | ~1,500 years |
The takeaway: length beats complexity.
A 16-character all-lowercase password (16 × log2(26) = 75.2 bits) is far harder to crack than an 8-character password full of numbers and symbols (8 × log2(94) = 52.4 bits).
For apps handling personal data, aim for at least 72 bits of entropy on sensitive accounts. High-security financial systems should require 90+ bits.
2. Generating Passwords and API Keys
If your backend auto-generates temporary passwords, session tokens, or API keys, you have to use a secure random number generator. A mistake here compromises the whole platform.
Why Math.random() fails
Never use Math.random() in JavaScript or random.random() in Python for security tokens. These are pseudo-random generators (PRNGs), usually seeded by the system clock and following deterministic formulas. An attacker who knows the algorithm and the approximate timestamp can predict the output.
// ❌ Vulnerable to prediction attacks
function insecurePasswordGenerator(length) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
// Math.random() is NOT cryptographically secure — it's predictable.
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
The fix: a CSPRNG
Use the OS’s cryptographically secure random number generator (CSPRNG), which gathers entropy from hardware events (mouse movement, key timings, thermal noise).
// ✅ Using the Node.js / Web Crypto API
function securePasswordGenerator(length) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
// Allocate a typed array for random 32-bit integers
const array = new Uint32Array(length);
// Fill it with cryptographically secure random values
crypto.getRandomValues(array);
// Map the random integers to the character pool
return Array.from(array, (num) => chars[num % chars.length]).join('');
}
3. Hashing: Destroying the Plaintext
Rule #1: never store passwords in plaintext.
If your database is breached via SQL injection and passwords are plaintext, it’s over. Run passwords through a one-way hash. But fast hashes like MD5, SHA-1, and even SHA-256 are dangerous for password storage.
Why fast hashes (like SHA-256) fail
GPUs are built to run millions of parallel operations. A single high-end consumer GPU can compute over 10 billion SHA-256 hashes per second. Hash your passwords with SHA-256 and an attacker with a modest GPU cluster can brute-force millions of 8-character passwords in hours.
The fix: slow, key-stretching algorithms
Password hashing algorithms are deliberately slow and expensive. They run thousands of internal iterations to bottleneck the attacker’s hardware.
A. bcrypt (the proven standard)
Introduced in 1999, bcrypt is still the right choice for most web apps. It has a configurable “work factor” (the cost), and the cost is logarithmic — raising it from 10 to 12 makes the algorithm take four times longer. That keeps bcrypt secure as hardware speeds up.
import bcrypt from 'bcrypt';
// The cost factor controls the slowness.
// A cost of 12 takes roughly 250ms on a modern server CPU.
const saltRounds = 12;
const secureHash = await bcrypt.hash(userPassword, saltRounds);
// Later, during the /login route:
const isValid = await bcrypt.compare(inputPassword, secureHash);
if (!isValid) throw new Error("401 Unauthorized");
B. Argon2id (the modern choice)
Argon2 won the 2015 Password Hashing Competition.
bcrypt needs CPU power, but attackers learned to build custom ASIC chips designed to compute bcrypt hashes quickly.
Argon2id counters this by being memory-hard — it requires gigabytes of RAM to run. Since fast RAM is expensive to put on a custom ASIC, Argon2 neutralizes hardware-accelerated attacks.
import argon2 from 'argon2';
// A memory-hard Argon2id hash
const secureHash = await argon2.hash(password, {
type: argon2.argon2id, // The recommended hybrid variant
memoryCost: 65536, // Use 64 MB of RAM per hash
timeCost: 3, // CPU iterations
parallelism: 4, // CPU threads
});
Recommendation: for new projects, default to Argon2id. For legacy projects, or RAM-constrained environments like small AWS Lambda functions, bcrypt is still secure and more practical.
4. Defeating Rainbow Tables with Salting
Before slow hashing became standard, attackers used “rainbow tables” — huge precomputed databases mapping plaintext passwords to their hashes.
With a stolen database, an attacker wouldn’t compute hashes; they’d run a JOIN against their rainbow table to instantly crack everyone whose password was “password123”.
Salting kills rainbow tables. A salt is a long, random string added to the password before hashing.
Hash 1: SHA256("password123") = [Same predictable hash for everyone]
Hash 2: bcrypt("password123" + "RANDOM_SALT_XYZ987") = [Unique hash A]
Hash 3: bcrypt("password123" + "RANDOM_SALT_ABC123") = [Unique hash B]
Because every user gets a unique random salt at registration, a precomputed table is useless — the attacker would have to build a new table per user, which isn’t feasible.
(Modern libraries like bcrypt and argon2 generate, embed, and manage the salt automatically in the output. Don’t write your own salting logic.)
5. Modern Password Policies (NIST)
For decades, IT departments enforced annoying rules: “Exactly 8 characters, one uppercase, one number, one symbol, change it every 90 days.”
NIST analyzed years of breach data and found these rules were actively hurting security. Users worked around them with predictable patterns like Spring2024! and Summer2024!.
NIST SP 800-63B guidelines
For a modern auth flow:
- Length over complexity: drop the requirements for special characters, numbers, and uppercase. Enforce a minimum length of at least 8 characters (12–15+ recommended).
- Support long passphrases: allow up to 64 characters, including spaces, so users can use memorable passphrases (
correct horse battery staple). - No mandatory expiration: don’t force changes every 90 days. Only require a change when there’s evidence of a breach.
- Block breached passwords: when a user sets a password, check it against a database of known-breached passwords (like the Have I Been Pwned k-Anonymity API) and reject it if it’s been compromised.
- Offer MFA: support multi-factor auth via TOTP apps (Google Authenticator) or hardware keys (FIDO2 / YubiKey). NIST discourages SMS-based 2FA because of SIM-swapping.
6. Implementation Checklist
Before deploying your auth service, verify:
- Password hashing happens server-side only. Never hash in the browser — it exposes the algorithm.
- Passwords are hashed with
Argon2idorbcrypt(cost ≥ 12). - Auth traffic runs over
HTTPS/TLS 1.2+. - Access logs (Nginx, Express Morgan, CloudWatch) are scrubbed so plaintext passwords from POST bodies are never logged.
- Rate limiting (e.g., 5 attempts per IP per minute) is enforced on
/loginto stop credential stuffing and brute-force bots.
Further Reading
- bcrypt vs SHA-256: Password Hashing Algorithms Compared
- Environment Variables and Secrets Management in Node.js
- The Complete Developer’s Guide to Encoding and Hashing
- Cross-Site Scripting (XSS): Protecting Session Tokens
Don’t rely on your browser’s default password suggestions. Generate high-entropy API keys and passwords with our local Secure Password Generator, and verify your backend hashes with the Bcrypt Generator.