How should a password be stored?
With a deliberately slow, salted hash — bcrypt, scrypt or Argon2, or PBKDF2 if you cannot add a dependency. SHA-256 is wrong precisely because it is fast: speed is the attacker's budget, and a database leak turns into account takeover at whatever rate your hash allows.
The Answer
Say this in the room. 45 seconds.
- Never store the password. Store a slow, salted hash of it, and never anything reversible.
- Use bcrypt, scrypt or Argon2id.
PBKDF2WithHmacSHA256is acceptable and is in the JDK, so "we had no library" is not a reason. - SHA-256 is wrong because it is fast. Fast is the whole property an attacker wants, and hashing speed is exactly their guessing budget.
- A salt is per user, random, and stored alongside the hash. It is not secret. It stops one cracked hash revealing every user with the same password.
- A pepper is an application-wide secret kept outside the database, so a database leak alone is not enough.
- Never encrypt a password: encryption is reversible, so the key becomes the thing that leaks.
- Compare hashes in constant time, and never log or return them.
Understand It
An unsalted hash leaks who shares a password
A hash function is deterministic, so the same input always gives the same output. Store bare SHA-256 and the database itself tells an attacker which accounts share a password — before any cracking starts:
String alice = sha256("hunter2");
String bob = sha256("hunter2");
String eve = sha256("Hunter2");
System.out.println(" alice : " + alice.substring(0, 32) + "...");
System.out.println(" bob : " + bob.substring(0, 32) + "...");
System.out.println(" same password, same hash? " + alice.equals(bob));
System.out.println(" one character different? " + (alice.equals(eve) ? "same" : "completely different")); alice : f52fbd32b2b3b86ff88ef6c490628285...
bob : f52fbd32b2b3b86ff88ef6c490628285...
same password, same hash? true
one character different? completely differentTwo consequences from those four lines.
Identical hashes identify shared passwords. Crack one and you have every account using it, and the most common password in a leaked table is usually shared by thousands.
That hash is a lookup key. f52fbd32... is the SHA-256 of a well-known password, and it is in every rainbow table on the internet — no cracking required, just a search. A per-user salt is what defeats this: it makes the precomputed table useless, because the attacker would need one per salt.
The last line shows the avalanche property doing its job. That is a good hash function behaving correctly — and it is not the property that matters here.
Speed is the attacker's budget
This is the point people miss. The attacker is not attacking the algorithm; they are guessing, offline, as fast as your choice of hash allows.
int shaCount = 100_000;
long t0 = System.nanoTime();
for (int i = 0; i < shaCount; i++) sha256("guess" + i);
double shaMs = (System.nanoTime() - t0) / 1_000_000.0;
byte[] probe = new byte[16];
int kdfCount = 5, iterations = 210_000;
t0 = System.nanoTime();
for (int i = 0; i < kdfCount; i++) pbkdf2(("guess" + i).toCharArray(), probe, iterations);
double kdfMs = (System.nanoTime() - t0) / 1_000_000.0;
double shaPerSec = shaCount * 1000.0 / shaMs;
double kdfPerSec = kdfCount * 1000.0 / kdfMs;
System.out.printf(" SHA-256, unsalted : roughly %.0f guesses per second%n", shaPerSec);
System.out.printf(" PBKDF2, %d rounds : roughly %.0f guesses per second%n", iterations, kdfPerSec);
System.out.printf(" slowed by a factor of : roughly %.0f%n", shaPerSec / kdfPerSec); SHA-256, unsalted : roughly 72906 guesses per second
PBKDF2, 210000 rounds : roughly 1 guesses per second
slowed by a factor of : roughly 104505Those numbers vary with the machine — this block is verified for shape, and the run above was on a busy one. That understates the problem rather than overstating it. A single modern GPU does billions of SHA-256 hashes a second, not tens of thousands, so the real-world gap is far larger than five figures.
Put it in the terms that matter. Against unsalted SHA-256 on rented GPUs, every password in a leaked table that appears in a common wordlist falls in minutes. Against a properly tuned bcrypt or Argon2, the same wordlist takes so long that only high-value accounts are worth attempting.
You are not making cracking impossible. You are making it expensive enough that the leak is survivable — which is the honest goal, and the reason the parameter is called a work factor.
Salt, and verifying without a comparison bug
A salt is random per user, generated with a cryptographic source, stored in plain text next to the hash. It is not a secret; its job is to make every stored hash unique even when the passwords are not.
byte[] saltA = randomSalt();
byte[] saltB = randomSalt();
byte[] hashA = pbkdf2("hunter2".toCharArray(), saltA, 210_000);
byte[] hashB = pbkdf2("hunter2".toCharArray(), saltB, 210_000);
System.out.println(" same password, two salts -> same hash? " + sameHash(hashA, hashB));
byte[] again = pbkdf2("hunter2".toCharArray(), saltA, 210_000);
byte[] wrong = pbkdf2("hunter3".toCharArray(), saltA, 210_000);
System.out.println(" correct password verifies? " + sameHash(hashA, again));
System.out.println(" wrong password rejected? " + !sameHash(hashA, wrong)); same password, two salts -> same hash? false
correct password verifies? true
wrong password rejected? trueNote there is no decryption anywhere. Verification re-hashes the supplied password with the stored salt and compares — which is why a hash is enough and why you can never email someone their password.
Three details in that code that are easy to get wrong:
SecureRandom, notRandom.Randomis a predictable PRNG seeded from the clock. A salt from it is a salt an attacker can regenerate.MessageDigest.isEqual, notArrays.equalsorString.equals. The ordinary comparisons return as soon as they find a difference, so how long a login takes leaks how much of the hash matched. Constant-time comparison closes that.char[], notString. AStringcannot be cleared and stays in the heap — and in any heap dump — until collected.
Choosing the algorithm and the parameters
| Algorithm | Verdict |
|---|---|
| Argon2id | Best available. Memory-hard, so GPUs and ASICs help the attacker much less |
| scrypt | Also memory-hard, well understood |
| bcrypt | Fine, and the pragmatic default for Java — battle-tested, in Spring Security |
| PBKDF2-HMAC-SHA256 | Acceptable, FIPS-approved, in the JDK. Not memory-hard |
| MD5, SHA-1, SHA-256, SHA-3 | Wrong. Fast by design |
| Encryption of any kind | Wrong. Reversible, so the key becomes the target |
| Your own construction | Wrong. Including SHA-256 applied a thousand times |
The parameter — cost factor, iterations, memory — should be tuned so one hash takes roughly 250 milliseconds on your production hardware. That is unnoticeable at login and ruinous at scale. Re-tune it every couple of years, because the attacker's hardware improves and yours does too.
Peppering adds an application-wide secret, held outside the database in a secrets manager or an HSM, mixed in before hashing. A database leak alone is then not enough to start cracking. It is genuinely useful and it complicates rotation, so it is a decision rather than a default.
What surrounds it, which matters as much
Storage is one control. The failures that actually reach the news usually involve the rest:
- Rate limit and lock out, per account and per IP. Offline cracking needs the leak first; online guessing does not.
- Check against known-breached passwords at registration — the Pwned Passwords range API does it without sending the password.
- Length over composition. Minimum 12, no forced special characters, no maximum below 64, and no truncation. Note that bcrypt silently truncates at 72 bytes, which is a real edge case with long passphrases.
- Never log it. Not at DEBUG, not in a request dump, not in an exception message.
- Rehash on login when you change the cost factor, since you cannot recompute a hash without the password.
Reference
The correct implementation, the configuration, and the migration path. Copy from here.
Spring Security, which is what most Java services should use
@Bean
PasswordEncoder passwordEncoder() {
// Prefixes each hash with {bcrypt}, {argon2} and so on, so the stored
// value records how it was made — which is what makes migration possible.
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// Registration
String stored = encoder.encode(rawPassword); // salt is generated and embedded
// Login — never compare strings yourself
if (!encoder.matches(rawPassword, stored)) throw new BadCredentialsException("bad login");
// Re-hash when the cost factor changes, or when an old algorithm is detected
if (encoder.upgradeEncoding(stored)) {
userRepository.updatePassword(user.id(), encoder.encode(rawPassword));
}
A stored bcrypt hash looks like this, and carries its own parameters:
{bcrypt}$2a$12$Nn0Xy8kQ8Zz1cJ4bX1V8Ye7pQ...
│ │ └── 22-char salt + 31-char hash
│ └───── cost factor: 2^12 rounds
└──────── algorithm version
PBKDF2 with no dependencies
import java.security.MessageDigest;
import java.security.SecureRandom;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
public final class Passwords {
private static final int ITERATIONS = 210_000; // OWASP, PBKDF2-HMAC-SHA256
private static final int KEY_BITS = 256;
private static final int SALT_BYTES = 16;
public static byte[] newSalt() {
byte[] salt = new byte[SALT_BYTES];
new SecureRandom().nextBytes(salt);
return salt;
}
public static byte[] hash(char[] password, byte[] salt) throws Exception {
PBEKeySpec spec = new PBEKeySpec(password, salt, ITERATIONS, KEY_BITS);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec).getEncoded();
} finally {
spec.clearPassword(); // clears the copy it made
}
}
public static boolean verify(char[] password, byte[] salt, byte[] expected) throws Exception {
byte[] actual = hash(password, salt);
return MessageDigest.isEqual(actual, expected); // constant time
}
}
// At the call site, clear the password when you are done with it.
char[] password = request.password();
try {
if (!Passwords.verify(password, user.salt(), user.hash())) reject();
} finally {
java.util.Arrays.fill(password, '\0');
}
Recommended parameters, as of 2026
| Algorithm | Setting |
|---|---|
| Argon2id | 19 MiB memory, 2 iterations, parallelism 1 |
| bcrypt | cost 12 (tune to ~250ms on your hardware) |
| scrypt | N=2^17, r=8, p=1 |
| PBKDF2-HMAC-SHA256 | 600,000 iterations |
Measure on production hardware and re-check yearly. A cost factor that was right in 2020 is not right now.
Migrating a table of bad hashes
You cannot recompute a hash without the password, so migration happens at login:
// 1. Store the algorithm with the hash if it is not already recorded.
// 2. On successful login against the OLD scheme, re-hash with the new one.
if (legacySha256(raw).equals(user.hash())) {
userRepository.updatePassword(user.id(), encoder.encode(raw)); // upgraded
return ok();
}
// 3. Or wrap immediately: store bcrypt(sha256(password)) for every row today,
// which protects the whole table at once without waiting for logins, at the
// cost of one extra layer to unwind later.
// 4. Force a reset for accounts that never log in, and set a deadline.
Option 3 is the one to know: it upgrades the entire table in a single migration rather than leaving weak hashes for dormant accounts.
Scenarios
Real situations, with the decision and the argument.
1. A legacy table with unsalted MD5, and a deadline.
You cannot reverse them, so there is no batch job that produces correct bcrypt hashes. There are two routes and they combine.
Wrap immediately: store bcrypt(md5(password)) for every row today. The whole table is protected in one migration, dormant accounts included, and the cost is one layer to unwind later. Then upgrade properly at each login, where the plaintext is briefly available, and force a reset for accounts that have not logged in by the deadline.
What is not acceptable is leaving the weakest rows — dormant accounts are exactly the ones nobody is watching.
2. Someone proposes encrypting passwords so support can help users who forget them.
That requirement is the problem, not the implementation. If support can read it, so can anyone who obtains the key — and the key lives close to the database it protects.
Password reset is the answer to the underlying need: a one-time token, short-lived, sent to a verified channel. This is worth pushing back on rather than engineering around, because "we can recover your password" is itself the disclosure that a company stores them reversibly.
3. Login is taking 400ms and someone wants to lower the bcrypt cost factor.
Ask what fraction of requests are logins. It is usually a fraction of a percent, and 400ms on a login nobody performs twice a minute is not the bottleneck they are looking for.
If it genuinely is — a service doing thousands of logins a second — the fix is a session or token so a password is verified once rather than per request, not weakening the hash. Lowering the cost factor is trading a permanent security property for a latency win in the one place latency does not matter.
4. The security scanner reports the login endpoint leaks whether an account exists.
Two separate leaks. The obvious one is the message — "no such user" versus "wrong password". The subtle one is timing: if a missing user returns immediately and an existing user takes 250ms while bcrypt runs, the response time answers the question anyway.
Return an identical message, and hash against a dummy value when the user does not exist so both paths cost the same. Whether it is worth fixing depends on the product — a bank yes, a public forum where usernames are visible anyway, probably not.
5. A team wants to use SHA-256 because it is FIPS-approved and bcrypt is not.
The constraint is real and the conclusion is wrong. PBKDF2-HMAC-SHA256 is FIPS-approved and is a password-based key derivation function with a tunable iteration count — it satisfies both requirements, and it is in the JDK.
This is worth knowing precisely, because "our compliance regime does not allow bcrypt" is a genuine constraint that gets used to justify a bare hash. The answer is not to argue about bcrypt; it is that PBKDF2 at 600,000 iterations is available, approved, and correct.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "How should a password be stored?" A slow, salted hash — bcrypt, scrypt or Argon2id, or PBKDF2 if a dependency is not possible. Never reversible, never a fast hash, and the work factor tuned so one hash takes around 250ms on production hardware.
2. "Why is SHA-256 wrong? It is a strong hash." It is strong against collisions and wrong for this, because it is fast. An attacker with the leaked table guesses offline at whatever rate your hash permits — billions a second on a GPU for SHA-256. Speed is the attacker's budget.
3. "What does a salt do, and is it secret?" Not secret. It is random per user and stored beside the hash, so identical passwords produce different hashes — which defeats precomputed rainbow tables and stops one cracked hash revealing every account sharing that password.
4. "What is a pepper?" An application-wide secret kept outside the database, mixed in before hashing, so a database leak alone is not enough to begin cracking. Useful, and it complicates rotation, so it is a deliberate choice.
5. "How do you verify a login without decrypting anything?" Re-hash the supplied password with the stored salt and compare the result in constant time. There is no decryption, which is exactly why a password can never be emailed back to a user.
6. "Why constant-time comparison?"
Arrays.equals returns at the first difference, so response time leaks how many bytes matched. MessageDigest.isEqual compares the whole length regardless.
7. "How would you migrate a table of unsalted MD5?" You cannot recompute them. Wrap them — store bcrypt over the existing hash so the whole table is protected at once — and upgrade properly at each login where the plaintext is briefly available, with a forced reset and a deadline for dormant accounts.
8. "Your login takes 300ms because of the hash. Is that a problem?" No, and it is the design. Logins are rare per user; 300ms is unnoticeable to a person and ruinous to someone guessing. If login volume genuinely is the bottleneck, issue a session or token rather than weakening the hash.
Code traps
Trap A — predict before you run:
String salt = UUID.randomUUID().toString();
String hash = sha256(salt + password);
Answer
Better than no salt, and still wrong. The salt defeats rainbow tables, but the hash is still SHA-256 — fast — so an attacker with the table brute-forces each account at billions of guesses a second. Salting fixes precomputation, not speed, and they are separate problems.
Also worth noting: UUID.randomUUID() is cryptographically random, so it is an acceptable salt source, unlike Random. The problem here is entirely the choice of hash.
Trap B:
if (storedHash.equals(computeHash(password, salt))) {
return issueSession();
}
Answer
String.equals short-circuits on the first differing character, so the time taken reveals how much of the hash matched. Over enough requests that is a usable oracle.
MessageDigest.isEqual(a, b) compares the full length regardless. The wider point is that comparing secrets — hashes, tokens, HMAC signatures, API keys — always wants a constant-time comparison, and this is the trap that recurs in every one of those places.
Trap C:
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
log.info("login attempt: {}", request); // record with a password field
...
}
Answer
The generated toString on the request record includes the password, so every login attempt writes a plaintext password into the log — which is shipped to an aggregator, retained for months, and readable by anyone with log access.
Override toString to redact it, annotate it for your logging setup, or do not log the object. This is one of the most common ways passwords actually leak, and it involves no attacker at all — the storage was correct and the logs were not.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "SHA-256, it's secure." | Strong against collisions, wrong here because it is fast. |
| "We encrypt passwords." | Reversible. The key becomes the target, and support can read them. |
| "The salt must be kept secret." | It is stored beside the hash. Its job is uniqueness, not secrecy. |
| "Hashing twice makes it stronger." | Marginally slower, not a work factor. Use a real KDF. |
| "bcrypt is old, use SHA-3." | SHA-3 is fast. Age is not the axis; work factor is. |
| "We can email a forgotten password." | Then it is stored reversibly, which is the whole finding. |
Check Yourself
Q1. Why is a fast hash the wrong choice, given that SHA-256 is cryptographically strong?
Answer
Strength against collisions is not the property under attack. An attacker with the leaked table guesses offline, and their rate is exactly your hashing speed — billions a second for SHA-256 on a GPU. A password hash is deliberately slow, and the parameter is called a work factor for that reason.
Q2. Is the salt secret, and what does it actually prevent?
Answer
Not secret — it is stored beside the hash. It makes every stored hash unique even for identical passwords, which defeats precomputed rainbow tables and stops one cracked hash exposing every account that shares that password. It does nothing about guessing speed; the work factor does that.
Q3. You inherit a table of unsalted SHA-256 hashes. What do you do first?
Answer
You cannot recompute them without the passwords. Wrap them immediately — store bcrypt over the existing hash — so the entire table including dormant accounts is protected in one migration, then upgrade to a clean bcrypt hash at each login and force a reset with a deadline for accounts that never return.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Crack your own hashes | 10 min |
| Challenge | Fix five storage schemes | 25 min |
| Production | The table that was salted and still broken | 45 min |
| Interview | Full round replay | 10 min |
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Crack your own hashes
One concept, guided. Near-impossible to fail.
- Challenge25 min
Fix five storage schemes
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The table that was salted and still broken
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — password storage
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- sec jwt and sessions — not written yet
- sec secrets management — not written yet
- sec injection — not written yet
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-28.