Password Security: Encoding, Encryption & Hashing
"Just hash the password" hides three genuinely different techniques with very different security properties. This chapter is why only one of the three (hashing) is ever correct for passwords.
Learning objectives
- Beginner: Explain why encoding (like Base64) is not a security mechanism at all.
- Intermediate: Configure and use Spring Security's PasswordEncoder correctly for storing and verifying passwords.
- Advanced: Explain what CompromisedPasswordChecker adds on top of correct hashing, and why hashing alone isn't sufficient defense.
🌱 BEGINNER STORY
Imagine three ways to protect a secret note: (1) Write it in a simple substitution code that anyone with the codebook can instantly translate back — that's like ENCODING (Base64): it's a format transformation, not real protection. (2) Lock it in a safe with a key — someone WITH the key can always open the safe and read the original note back — that's ENCRYPTION: reversible, but only by whoever holds the key. (3) Feed the note into a paper shredder that turns it into confetti in a completely unique, repeatable pattern — you can never reconstruct the original note from the confetti, but if someone hands you a new note, you can shred IT the same way and compare the confetti patterns to check if it's the same original note. That's HASHING — and that's exactly why we hash passwords instead of encoding or encrypting them: we never need to get the original password back, we only ever need to check 'does this new attempt match what I stored?'
Figure: The same plain-text password run through Encoding, Encryption, and Hashing — only hashing is a one-way street.
Figure: Side-by-side comparison of Encoding, Encryption, and Hashing.
Encoding (like Base64 or URL-encoding) exists purely to represent data in a different FORMAT for safe transport (e.g., embedding binary data in a URL or JSON string) — it uses no secret key at all, so decode() instantly reverses it. If you 'protect' a password with Base64, anyone who sees the encoded string can decode it in one line of code. It is not a security mechanism at all.
String encoded = Base64.getEncoder().encodeToString("MyPass123".getBytes());
// TXlQYXNzMTIz -- looks obscured, but instantly reversible: String original = new String(Base64.getDecoder().decode(encoded)); // back to "MyPass123" -- zero real protection
Encryption (e.g., AES) IS reversible, but only with the correct key — which sounds better, but for passwords it's still the wrong tool: if the encryption key is ever compromised (misconfigured secrets manager, leaked config file, insider threat), EVERY password in the database can be decrypted back to plain text in one shot. Encryption is the right tool for data you legitimately need to read again later (like a stored credit card number for a recurring payment) — but a password is never something the application legitimately needs to read back; it only ever needs to VERIFY a match.
A cryptographic hash function takes an input of any size and produces a fixed-size output (the 'digest') such that: (1) the same input always produces the same output, (2) it's computationally infeasible to reverse the output back to the input, and (3) even a tiny change in input produces a wildly different output (the avalanche effect). For passwords specifically, we use SLOW, purpose-built password-hashing algorithms (BCrypt, SCrypt, Argon2, PBKDF2) rather than fast general-purpose hashes (MD5, SHA-256) — because slowness is a FEATURE here, not a bug.
-
Brute-force attacks: an attacker with the hash tries every possible password combination until one matches. A fast hash function (like plain SHA-256) lets attackers try billions of guesses per second on modern GPUs.
-
Dictionary attacks: instead of every combination, the attacker tries a curated list of common/leaked passwords ("password123", "qwerty", etc.) — surprisingly effective because humans are predictable.
-
Rainbow table attacks: an attacker precomputes hashes for millions of common passwords ONCE, then just looks up your leaked hash in that precomputed table — instant reversal, no brute-forcing needed at attack time.
The fix for all three is the same: (a) use a computationally EXPENSIVE hash algorithm (BCrypt takes milliseconds per hash by design — fine for one login, brutal at scale for an attacker), and (b) use a unique random SALT per password so identical passwords across different users produce completely different hashes, making precomputed rainbow tables useless.
PasswordEncoder is the interface Spring Security uses to hash and verify passwords. It has exactly two methods that matter: encode(rawPassword) to hash a new password, and matches(rawPassword, encodedPassword) to verify a login attempt — note there is deliberately NO decode() method, because that would defeat the entire purpose.
| Implementation | Notes |
|---|---|
| BCryptPasswordEncoder | Industry-standard default. Includes a built-in random salt and a configurable 'strength' (work factor, default 10) controlling how slow it is. |
| Argon2PasswordEncoder | Winner of the Password Hashing Competition (2015); tunable for both CPU and memory cost — currently considered the strongest general recommendation. |
| Pbkdf2PasswordEncoder | NIST-recommended, widely used in regulated/government contexts; configurable iteration count. |
| SCryptPasswordEncoder | Memory-hard, designed to resist GPU/ASIC-based cracking. |
| NoOpPasswordEncoder | Stores plain text — for tests ONLY, never for anything resembling production. |
| DelegatingPasswordEncoder | The actual default returned by PasswordEncoderFactories.createDelegatingPasswordEncoder() — prefixes stored hashes like {bcrypt}$2a$10$... so the app can support multiple encoders and migrate algorithms over time without breaking existing users. |
@Bean
public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // Produces hashes like: {bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMy... // The {bcrypt} prefix lets Spring Security know which algorithm to // use for matches() later -- even if you change the default encoder // for NEW passwords, OLD hashes with a different prefix still verify correctly. }
Spring Security 6.3+ ships CompromisedPasswordChecker, which checks a candidate password against the 'Have I Been Pwned' breached-password dataset (using k-anonymity, so the raw password is never sent over the network) — this lets you reject passwords like 'password123' at registration time even if they've never been used on YOUR system, because they're already known to be compromised elsewhere.
@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() { return new HaveIBeenPwnedRestApiPasswordChecker(); } // usage during registration: CompromisedPasswordDecision decision = compromisedPasswordChecker.check(rawPassword); if (decision.isCompromised()) { throw new IllegalArgumentException("This password has appeared in a data breach. Choose another."); }
⚠ Production traps around passwords
Changing your PasswordEncoder algorithm doesn't retroactively re-hash existing users' passwords — you must either use DelegatingPasswordEncoder (which tags each hash with its algorithm) or run a migration that re-hashes passwords the next time each user successfully logs in with their OLD password. BCrypt has a hard input length limit of 72 bytes — anything beyond that is silently truncated and ignored, meaning 'SuperLongPassword...(80 chars of unique suffix)' and the same string with a DIFFERENT 80-char suffix can hash identically if the first 72 bytes match. Pre-hashing very long inputs with SHA-256 before BCrypt is a common mitigation. Storing password hashes in a column that's too short (e.g., VARCHAR(60) sized for old BCrypt output) breaks silently when you switch to Argon2 or add the DelegatingPasswordEncoder's algorithm prefix, which needs more characters — size the column generously (VARCHAR(255)+). Rate-limiting login attempts is still necessary even with strong hashing — hashing protects a LEAKED database, but a live login endpoint without rate-limiting is still vulnerable to online brute-force/credential-stuffing attacks.
Want a visual for this concept?
Generate a diagram tailored to “Password Security: Encoding, Encryption & Hashing” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →