How should a password be stored?

Asked constantlyintermediate1–12 yrs11 min read

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. PBKDF2WithHmacSHA256 is 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:

Compiled and run on this build
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"));
Output
  alice : f52fbd32b2b3b86ff88ef6c490628285...
  bob   : f52fbd32b2b3b86ff88ef6c490628285...
  same password, same hash? true
  one character different?  completely different

Two 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.

Compiled and run on this build — output varies between runs
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);
Output
  SHA-256, unsalted     : roughly 72906 guesses per second
  PBKDF2, 210000 rounds : roughly 1 guesses per second
  slowed by a factor of : roughly 104505

Those 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.

Compiled and run on this build
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));
Output
  same password, two salts -> same hash? false
  correct password verifies?             true
  wrong password rejected?               true

Note 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, not Random. Random is a predictable PRNG seeded from the clock. A salt from it is a salt an attacker can regenerate.
  • MessageDigest.isEqual, not Arrays.equals or String.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[], not String. A String cannot be cleared and stays in the heap — and in any heap dump — until collected.

Choosing the algorithm and the parameters

AlgorithmVerdict
Argon2idBest available. Memory-hard, so GPUs and ASICs help the attacker much less
scryptAlso memory-hard, well understood
bcryptFine, and the pragmatic default for Java — battle-tested, in Spring Security
PBKDF2-HMAC-SHA256Acceptable, FIPS-approved, in the JDK. Not memory-hard
MD5, SHA-1, SHA-256, SHA-3Wrong. Fast by design
Encryption of any kindWrong. Reversible, so the key becomes the target
Your own constructionWrong. 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');
}
AlgorithmSetting
Argon2id19 MiB memory, 2 iterations, parallelism 1
bcryptcost 12 (tune to ~250ms on your hardware)
scryptN=2^17, r=8, p=1
PBKDF2-HMAC-SHA256600,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 interviewsReality
"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?

AnswerStrength 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?

AnswerNot 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?

AnswerYou 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

TierExerciseTime
Warm-upCrack your own hashes10 min
ChallengeFix five storage schemes25 min
ProductionThe table that was salted and still broken45 min
InterviewFull round replay10 min

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

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.