Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Password Management & Encoding

Storing passwords properly: choosing between BCrypt, Argon2 and scrypt, DelegatingPasswordEncoder for zero-downtime migration, strength rules, and breached-password checks.

Beginner5 min readUpdated
On this page

Password storage is a solved problem with a small number of correct answers, and the wrong answers are catastrophic rather than merely suboptimal. Spring Security gets the defaults right; what needs deciding is the algorithm, the cost parameters and the migration path.

Key Takeaways

  • Use a slow, salted, adaptive hash. Never SHA-256, never MD5, never a hash you built.
  • Argon2id is the current best choice; BCrypt remains fine.
  • DelegatingPasswordEncoder makes hash migration transparent and gradual.
  • Cost parameters are a moving target — revisit them every couple of years.
  • Check new passwords against breach corpora; length matters far more than symbol rules.

Why general-purpose hashes are wrong

SHA-256 is designed to be fast, which is exactly what you do not want. A modern GPU computes billions of SHA-256 hashes per second, so a stolen table of SHA-256 password hashes is a table of plaintext passwords within hours.

Password hashes are deliberately slow and tunable. A cost parameter sets how much work verification takes; you raise it as hardware improves. Verifying one password at 100ms is invisible to a user and makes brute-forcing millions of guesses impractical.

Salting is the other half. A random per-password salt means identical passwords produce different hashes, which defeats rainbow tables and stops an attacker from seeing that two accounts share a password.

The encoders

EncoderHardnessNotes
Argon2PasswordEncoderMemory + CPUCurrent recommendation (Argon2id)
BCryptPasswordEncoderCPUMature, ubiquitous, still fine
SCryptPasswordEncoderMemory + CPUGood, less common than the other two
Pbkdf2PasswordEncoderCPUChoose when FIPS compliance requires it
NoOpPasswordEncoderNoneDeprecated. Tests only, never anywhere else

Memory-hardness is what distinguishes Argon2 and scrypt. Custom hardware can parallelise CPU work cheaply but cannot cheaply parallelise something that needs 64MB of RAM per guess, so memory-hard functions raise the attacker's cost far more than the defender's.

PasswordConfig.java
@Configuration
public class PasswordConfig {
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        String encodingId = "argon2";
 
        Map<String, PasswordEncoder> encoders = new HashMap<>();
        // saltLength, hashLength, parallelism, memory (KB), iterations.
        // Tune so verification takes 250-500ms on production hardware.
        encoders.put("argon2", new Argon2PasswordEncoder(16, 32, 1, 1 << 16, 3));
        // Legacy algorithms stay registered so existing hashes still verify.
        encoders.put("bcrypt", new BCryptPasswordEncoder(12));
        encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
 
        var delegating = new DelegatingPasswordEncoder(encodingId, encoders);
        // Hashes stored before prefixes existed are assumed BCrypt rather than
        // throwing. Remove this once the migration is complete.
        delegating.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder(10));
        return delegating;
    }
}

Stored hashes carry their algorithm as a prefix, which is what makes several coexist:

{argon2}$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
{bcrypt}$2a$12$GhvMmNVjRW29ulnudl.LbuAnUtN/LRfe1JsBm1Xu6LE3059z5Tr8m

Migrating without a password reset

The plaintext is available for exactly one moment — during a successful login. That is when to upgrade the hash.
UpgradingAuthenticationSuccessHandler.java
@Component
public class HashUpgradeListener {
 
    private final UserRepository users;
    private final PasswordEncoder encoder;
 
    @EventListener
    public void onSuccess(AuthenticationSuccessEvent event) {
        Authentication auth = event.getAuthentication();
        String rawPassword = String.valueOf(auth.getCredentials());
        if (!StringUtils.hasText(rawPassword)) return;
 
        users.findByEmailIgnoreCase(auth.getName()).ifPresent(user -> {
            if (encoder.upgradeEncoding(user.passwordHash())) {
                user.setPasswordHash(encoder.encode(rawPassword));
                users.save(user);
                log.info("upgraded password hash for user {}", user.id());
            }
        });
    }
}

The population migrates itself as people log in. After a few months, force a reset for the remaining dormant accounts and remove the legacy encoder.

Strength rules that help

Most password policies actively reduce security. Requiring one uppercase, one digit and one symbol pushes users toward Password1! — which is in every cracking dictionary — while banning the long passphrase that would actually be strong.

Current guidance, from NIST SP 800-63B onwards, is straightforward: enforce a minimum length of at least 12, allow everything including spaces and unicode, set a generous maximum such as 128, do not require composition rules, do not expire passwords on a schedule, and do check against known-breached passwords.

PasswordPolicy.java
@Component
public class PasswordPolicy {
 
    private final PasswordValidator validator = new PasswordValidator(List.of(
            new LengthRule(12, 128),
            new WhitespaceRule(WhitespaceRule.Whitespace.TAB),
            // Reject sequences and repeats, which look complex and are not.
            new RepeatCharacterRegexRule(4),
            new IllegalSequenceRule(EnglishSequenceData.Alphabetical, 5, false),
            new IllegalSequenceRule(EnglishSequenceData.Numerical, 5, false),
            new UsernameRule(true, true)));
 
    private final BreachedPasswordChecker breached;
 
    public void assertAcceptable(String password, String username) {
        var result = validator.validate(new PasswordData(username, password));
        if (!result.isValid()) {
            throw new WeakPasswordException(validator.getMessages(result));
        }
        if (breached.isBreached(password)) {
            throw new WeakPasswordException(List.of(
                "This password has appeared in a known data breach. Please choose another."));
        }
    }
}

Checking against breaches

Have I Been Pwned exposes a k-anonymity API: you send the first five characters of the SHA-1 hash and receive every suffix with that prefix. The service never learns the password, and neither does anyone observing the request.

BreachedPasswordChecker.java
@Component
public class BreachedPasswordChecker {
 
    private final RestClient client;
 
    public boolean isBreached(String password) {
        String sha1 = DigestUtils.sha1Hex(password).toUpperCase(Locale.ROOT);
        String prefix = sha1.substring(0, 5);
        String suffix = sha1.substring(5);
 
        try {
            String body = client.get()
                    .uri("https://api.pwnedpasswords.com/range/{prefix}", prefix)
                    .retrieve().body(String.class);
 
            return body != null && body.lines()
                    .anyMatch(line -> line.startsWith(suffix));
 
        } catch (RestClientException ex) {
            // Fail open: a third-party outage must not block registration.
            log.warn("breach check unavailable", ex);
            return false;
        }
    }
}

Protecting the login endpoint

Strong hashing protects the database; it does nothing against online guessing. Three controls close that gap.

Rate limit per account and per IP. Per-account stops targeted guessing; per-IP stops credential stuffing spread across many accounts. You need both, because either alone is trivially bypassed.

Lock progressively, not permanently. A permanent lock after five failures is a denial-of-service against your own users — anyone can lock any account by guessing wrong. Exponential delays, or a timed lock that clears itself, give the same protection without the weapon.

Return one message for every failure. "No such user" and "wrong password" as distinct responses is a free account-enumeration oracle. Watch timing too: returning instantly for an unknown user and after 300ms of hashing for a known one leaks the same information.

What to take away

Use Argon2id or BCrypt through a DelegatingPasswordEncoder, tune the cost to a few hundred milliseconds, and let logins migrate old hashes for you. Require length rather than composition, check against breach corpora, and rate-limit the login endpoint — the hash protects the dump, not the front door.

Frequently Asked Questions

BCrypt or Argon2?
Argon2id is the current recommendation — it resists GPU and ASIC attacks better because it is memory-hard, not just CPU-hard. BCrypt remains perfectly acceptable and is far more widely deployed. If you are starting fresh, choose Argon2id; if you already run BCrypt at a sensible strength, migrating is not urgent.
Do I need to store a salt column?
No. BCrypt, Argon2 and scrypt all generate a random salt per password and embed it in the output string alongside the cost parameters. Storing it separately is a sign someone is using a raw hash function, which is the actual problem.
How do I upgrade hashes without forcing a password reset?
DelegatingPasswordEncoder reads the {id} prefix on each stored hash and verifies with the matching algorithm, while encoding all new hashes with your chosen default. Re-encode on successful login and the population migrates itself as users return.

Related tutorials