Password Hashing

Why hashing is not encryption, why fast hashes are a vulnerability, and how bcrypt/Argon2 turn brute force from feasible into economically pointless

← Back to Index

What is Password Hashing — and Why is it Not Encryption?

Hashing is a one-way function: given the output, there is no key, no algorithm, and no amount of computation that recovers the input directly — the only way "back" is to guess inputs and hash each guess until one matches. Encryption is two-way by design: whoever holds the key can decrypt and recover the original plaintext. Passwords must never be stored with encryption, however strong, because encryption's entire value proposition — reversibility — is the one property a stored credential must never have. If your database is ever exfiltrated, a hashed password forces the attacker into a computationally expensive guessing game; an encrypted password is fully recovered the instant the key is also found, which in practice means the moment the same breach also reaches your application server or secrets store.

The second half of the problem is which hash function. A cryptographic hash built for speed and integrity checking — MD5, SHA-1, SHA-256 — is the wrong tool here for the opposite reason it's the right tool elsewhere: it's fast. A modern GPU can compute billions of SHA-256 hashes per second, which turns "guess every password until one matches" from theoretical into a same-day attack against any password of realistic length. Password hashing needs a function that is deliberately, tunably slow.

// BEFORE — reversible encryption "just in case we need to recover it"
@Entity
public class Customer {
    private String encryptedPassword;   // AES-encrypted with a key stored in application config
}

public void register(String rawPassword) {
    String encrypted = aesCipher.encrypt(rawPassword, secretKey);
    customer.setEncryptedPassword(encrypted);
    // If the database AND the key are ever both exposed — the same breach that
    // reaches your app server config typically reaches both — every password
    // in the table is immediately, fully recovered in plaintext. No guessing
    // required, no work factor to slow the attacker down.
}

// AFTER — one-way, adaptive hash; the plaintext password never persists anywhere
@Entity
public class Customer {
    private String passwordHash;   // e.g. $2a$12$N9qo8uLOickgx2ZMRZoMy... (bcrypt, self-describing)
}

public void register(String rawPassword) {
    customer.setPasswordHash(passwordEncoder.encode(rawPassword));
    // A breach now hands the attacker a hash they must brute-force one guess
    // at a time, at a cost per guess that bcrypt/Argon2 make deliberately high.
}

Hashing vs Encryption vs Encoding — The Confusion Every Junior Has Once

These three terms get used interchangeably in casual conversation and describe entirely different, non-interchangeable operations. Confusing them is how "we base64-encoded the password before storing it" ends up in a production codebase — base64 is not security at all, it's a reversible text representation with zero secret involved.

OperationReversible?Needs a key?PurposeAppropriate for passwords?
Encoding (Base64, URL-encode) Yes, trivially No Represent binary data as text-safe characters Never — provides zero confidentiality
Encryption (AES, RSA) Yes, with the key Yes Protect data that must later be recovered in original form Never for the stored credential itself — appropriate for things like a stored card token or a field that genuinely must be decrypted later
Fast cryptographic hash (SHA-256) No, by design No Integrity checks, checksums, digital signatures No — too fast, makes brute force cheap
Adaptive password hash (bcrypt, Argon2) No, by design No (salt is embedded, not secret) Verify a password without ever storing it recoverably Yes — this is what they were built for

Salt, Pepper, and Work Factor — The Three Levers

Salt — defeats precomputed lookup tables

/*
 * Without a salt, two users with the password "Password123!" produce the
 * identical hash. An attacker who precomputes a "rainbow table" — hashes for
 * billions of common passwords — cracks every matching row in the database
 * in one lookup, no per-password computation needed at attack time.
 *
 * A salt is random data generated per password and stored alongside the hash
 * (it is NOT secret — its only job is uniqueness). It's mixed into the input
 * before hashing, so identical passwords produce completely different hashes:
 *
 *   hash("Password123!" + salt_A) = "$2a$12$N9qo8uLOickgx2ZMRZoMy..."
 *   hash("Password123!" + salt_B) = "$2a$12$EixZaYVK1fsbw1ZfbX3OX..."
 *
 * This forces the attacker to attack each hash individually — a precomputed
 * table is useless because it would need one entry per possible salt value,
 * which defeats the entire point of precomputing.
 */

Work factor — the deliberate cost that scales with hardware

// BCryptPasswordEncoder's constructor argument is the "log rounds" cost factor:
// the algorithm runs 2^cost internal rounds. Each +1 DOUBLES the compute time.
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12);   // 2^12 = 4096 rounds — ~250ms per hash on typical server hardware in 2026
}

/*
 * This 250ms is imperceptible for one login attempt, but devastating for an
 * offline brute-force attacker who must pay that cost for every single guess,
 * for every single password in a stolen database. At 4 hashes/second on
 * commodity hardware instead of billions/second with a fast hash, an 8-character
 * random password goes from "cracked in hours" to "impractical within any
 * reasonable timeframe or budget."
 *
 * Cost factor must be revised upward over time — hardware gets faster, and a
 * work factor tuned in 2020 provides less real protection every year it goes
 * untouched. Periodically re-benchmark for ~200-300ms per hash on current
 * production hardware, and bump the cost factor accordingly at deploy time.
 */
Pepper — an application-level secret, kept out of the database entirely

A pepper is a secret value, shared across all passwords, stored in application configuration or a secrets manager — never in the database next to the hash. Its purpose is different from a salt's: even if the entire password table is exfiltrated, an attacker without the pepper cannot verify guesses at all, because every hash computation requires a value they don't have. It's defense against the specific scenario of "database leaked but application secrets did not" — a real, if narrower, threat model than a full server compromise. Peppering is not built into BCryptPasswordEncoder; if you want it, apply an HMAC with the pepper as key to the raw password before passing it to the encoder, and treat pepper rotation with the same care as any other secret rotation.

Argon2 — Why OWASP Recommends it Over bcrypt for New Systems

bcrypt has one dial: computational cost. Argon2 (the winner of the 2015 Password Hashing Competition) adds a second dimension — memory cost — which specifically defeats an attack class bcrypt doesn't: custom ASIC and GPU hardware that has cheap compute but limited fast memory per parallel unit. Forcing every guess to also allocate a configurable amount of RAM makes building cheap parallel-cracking hardware significantly harder. Use the Argon2id variant — it combines resistance to side-channel timing attacks with resistance to GPU cracking, which the plain Argon2i/Argon2d variants only cover individually.

@Bean
public PasswordEncoder passwordEncoder() {
    return new Argon2PasswordEncoder(
        16,     // salt length in bytes
        32,     // hash length in bytes
        1,      // parallelism
        1 << 16, // memory cost in KB — 65536 KB = 64 MB per hash computation
        3       // iterations
    );
}
// Spring Security's Argon2PasswordEncoder defaults to Argon2id under the hood.
// Tune memory cost to what your infrastructure can actually afford per login —
// 64MB per concurrent hash computation adds up fast under login-endpoint load,
// which is exactly the trade-off that makes it expensive for an attacker too.
AlgorithmCost dimensionStatus
MD5 / SHA-1 / SHA-256None — built for speedNever appropriate for passwords, regardless of salting
PBKDF2Compute only (iteration count)Acceptable, required in some compliance contexts (FIPS); weaker than bcrypt/Argon2 against GPU attacks
bcryptCompute only (log rounds)Solid, battle-tested default; still Spring Security's default encoder
Argon2idCompute + memory + parallelismCurrent OWASP recommendation for new systems

Spring Security in Practice — Encoding, Verifying, and Migrating Algorithms

// DTOs as records
public record RegistrationRequest(String email, String rawPassword) {}

@Service
public class CustomerRegistrationService {

    private final CustomerRepository customerRepository;
    private final PasswordEncoder passwordEncoder;

    // Constructor injection — single constructor, no @Autowired needed (Spring 4.3+)
    public CustomerRegistrationService(CustomerRepository customerRepository, PasswordEncoder passwordEncoder) {
        this.customerRepository = customerRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public Customer register(RegistrationRequest request) {
        Customer customer = new Customer();
        customer.setEmail(request.email());
        customer.setPasswordHash(passwordEncoder.encode(request.rawPassword()));
        // The raw password is never persisted, logged, or held longer than this call.
        return customerRepository.save(customer);
    }
}

// Verifying a login — never compare hashes with String.equals()
public boolean checkPassword(String rawPassword, String storedHash) {
    return passwordEncoder.matches(rawPassword, storedHash);
    // matches() re-derives the salt from the stored hash string itself (bcrypt and
    // Argon2 both embed it) and performs a constant-time comparison internally —
    // handling this yourself with == or .equals() on the raw bytes reintroduces
    // a timing side-channel that leaks how many leading bytes matched.
}

Migrating hash algorithms without forcing a password reset

// DelegatingPasswordEncoder reads a {id} prefix from the stored hash to know
// which algorithm verified it — this is what PasswordEncoderFactories provides
// by default, and it's the mechanism that makes algorithm migration painless.
@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    // New hashes are encoded with the current default (bcrypt as of this writing),
    // stored as "{bcrypt}$2a$10$...". A legacy row stored as "{noop}rawpassword"
    // or "{sha256}abc123..." from a previous system still verifies correctly —
    // the prefix tells DelegatingPasswordEncoder which encoder to delegate to.
}

// Upgrade-on-login pattern: transparently re-hash with the current algorithm
// the next time a user with a legacy hash successfully authenticates
@Service
public class LoginService {

    private final CustomerRepository customerRepository;
    private final PasswordEncoder passwordEncoder;

    public LoginService(CustomerRepository customerRepository, PasswordEncoder passwordEncoder) {
        this.customerRepository = customerRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public void login(String email, String rawPassword) {
        Customer customer = customerRepository.findByEmail(email)
            .orElseThrow(() -> new BadCredentialsException("Invalid credentials"));

        if (!passwordEncoder.matches(rawPassword, customer.getPasswordHash())) {
            throw new BadCredentialsException("Invalid credentials");
        }

        // upgradeEncoding() is exposed via PasswordEncoder's UserDetailsPasswordService
        // integration in Spring Security — conceptually: if the stored hash uses
        // an outdated prefix, re-encode with the current default and persist it,
        // now that we have the raw password in hand for this one request only.
        if (passwordEncoder instanceof DelegatingPasswordEncoder delegating
                && delegating.upgradeEncoding(customer.getPasswordHash())) {
            customer.setPasswordHash(passwordEncoder.encode(rawPassword));
            customerRepository.save(customer);
        }
    }
}
This is the one legitimate reason application code ever sees a raw password

Outside of the registration and login flows shown above, a raw password should never exist as a variable anywhere in your codebase — not in a log statement, not in an exception message, not in a DTO that gets serialized into an audit trail. The matches() call is the only place the plaintext is compared, and it should go out of scope immediately after.

Modern Password Policy — What NIST 800-63B Actually Changed

Older password policy guidance — mandatory special characters, forced rotation every 90 days — is now considered counterproductive by NIST's own current digital identity guidelines. Forced rotation trains users toward predictable, incrementing passwords (Summer2024!Summer2025!); character-class requirements push people toward writing passwords down or reusing a single "compliant" password everywhere. Length, not complexity, is the strongest lever a policy actually controls.

// A defensible modern policy for a customer-facing e-commerce account:
//
// - Minimum 12 characters, no arbitrary maximum below 64
// - No mandatory character-class composition rules
// - No forced periodic rotation (rotate only on suspected compromise)
// - Check against a known-breach corpus at registration/change time
// - Rate-limit login attempts; lock or delay after repeated failures

// Checking a password against known breaches via the HaveIBeenPwned k-anonymity
// API — only the first 5 characters of the SHA-1 hash are sent, never the
// password itself, so the third party never sees anything identifying
@Service
public class BreachedPasswordChecker {

    private final RestClient restClient;

    public BreachedPasswordChecker(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("https://api.pwnedpasswords.com").build();
    }

    public boolean isBreached(String rawPassword) {
        String sha1 = DigestUtils.sha1Hex(rawPassword).toUpperCase();
        String prefix = sha1.substring(0, 5);
        String suffix = sha1.substring(5);

        String response = restClient.get().uri("/range/{prefix}", prefix)
            .retrieve().body(String.class);

        return response.lines()
            .anyMatch(line -> line.startsWith(suffix));
        // Reject at registration, don't silently allow — a password already in a
        // public breach corpus is compromised before the account even exists.
    }
}

Best Practices and Common Pitfalls

✅ Do

  • Use bcrypt or Argon2id via PasswordEncoderFactories.createDelegatingPasswordEncoder() — never a fast hash, never reversible encryption
  • Let the encoder generate and embed its own salt — never implement salting by hand
  • Re-benchmark and raise the work factor periodically as hardware gets faster
  • Use passwordEncoder.matches() for verification — never compare hashes with == or .equals()
  • Enforce minimum length (12+) over character-class complexity, per current NIST guidance
  • Check new passwords against a breach corpus (k-anonymity API) at registration and password-change time
  • Rehash transparently on next successful login when migrating to a stronger algorithm — never force a mass password reset for an algorithm upgrade

❌ Don't

  • Don't store passwords with reversible encryption "in case we need to recover them" — a password should never be recoverable, by anyone, ever
  • Don't use MD5, SHA-1, or unsalted SHA-256 for passwords — they're fast, and fast is the one property you don't want here
  • Don't roll your own salting, peppering, or comparison logic — use the framework's PasswordEncoder, which already handles constant-time comparison correctly
  • Don't log the raw password anywhere, including in exception messages or failed-login audit trails
  • Don't force periodic password rotation — it degrades password quality without a corresponding security benefit
  • Don't email a user their password, ever — if you can send it to them, you're storing it in a form that shouldn't exist

Interview Questions

🎓 Junior level

Q: Why can't you "decrypt" a hashed password if you forget it?
Hashing is a one-way function — there is no key or algorithm that reverses it. This is intentional: the application never needs to recover the original password, only to verify that a newly submitted password produces the same hash as the one stored. "Forgot password" flows always issue a new password or reset token; they never recover the old one.

Q: Why is SHA-256 a poor choice for hashing passwords, even though it's a strong cryptographic hash?
SHA-256 is designed to be fast, which is exactly right for its intended uses (file integrity checks, digital signatures) and exactly wrong for passwords. A fast hash lets an attacker with a stolen password database try billions of guesses per second on modern GPU hardware. Password hashing needs a deliberately slow, tunable algorithm like bcrypt or Argon2, which make each guess cost real, meaningful time.

Q: What problem does a salt solve, and is it supposed to be secret?
A salt is random data mixed into the password before hashing so that identical passwords produce different hashes. It defeats precomputed rainbow-table attacks, which rely on identical inputs always producing identical outputs. The salt is not secret — it's stored alongside the hash — its value comes purely from being unique per password, not from being hidden.

🔥 Senior level

Q: Your system currently hashes passwords with SHA-256 and a per-user salt. You want to migrate to bcrypt without forcing a mass password reset. How?
Use a delegating encoder pattern: store an algorithm identifier prefix alongside (or embedded in) each hash — Spring Security's DelegatingPasswordEncoder does this via a {id} prefix. New registrations and password changes hash with bcrypt going forward. Existing SHA-256 hashes continue to verify correctly against their legacy encoder, since the prefix tells the delegating encoder which algorithm to use for that specific row. On every successful login, check whether the stored hash uses the outdated algorithm and, if so, re-hash the now-available raw password with bcrypt and persist it — accounts migrate transparently, one login at a time, with no forced reset and no downtime.

Q: What is the practical difference between what bcrypt's cost factor tunes and what Argon2's parameters tune, and why does that difference matter against modern cracking hardware?
bcrypt's cost factor only controls computational time — doubling the cost factor doubles the CPU time per guess, but an attacker can still build cheap, highly parallel ASIC or GPU hardware with minimal memory per core, since bcrypt's memory footprint is small and fixed. Argon2 adds an independent memory-cost parameter: each guess must allocate a configurable amount of RAM (commonly tens of megabytes), and fast parallel memory at that scale is far more expensive to replicate across thousands of cores than raw compute is. This is why Argon2id is now the OWASP-recommended default for new systems — it specifically closes the economic advantage that custom cracking hardware has against compute-only algorithms.

Q: Why does NIST 800-63B recommend against forced periodic password rotation, and what should replace it operationally?
Forced rotation doesn't make a compromised password safer during the window it's already been stolen and unused, and empirically it degrades password quality — users respond to mandatory rotation with predictable, incrementing variations of a base password, which is easier to guess given any one previous value, or with a single password reused across the minimum required variations. The security goal rotation was meant to serve — limiting the value of a compromised credential over time — is better achieved by event-driven rotation (force a reset immediately on detected or suspected compromise, via breach-corpus checks or anomalous login detection) combined with MFA, which limits the value of a compromised password even before any rotation would occur.