Senior 7 min · May 23, 2026

Spring Security Password Encoding: BCrypt, Argon2 & DelegatingPasswordEncoder

Master Spring Security password encoding with BCryptPasswordEncoder, DelegatingPasswordEncoder migration from MD5/SHA, Argon2, and timing attack prevention.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use BCryptPasswordEncoder(strength) (strength 12 for production) to hash passwords before storing
  • Never store or compare raw passwords — always call passwordEncoder.matches(raw, encoded)
  • Use DelegatingPasswordEncoder to migrate from legacy MD5/SHA hashes without forcing password resets
  • Prefer Argon2 or BCrypt over SHA-based encoders — they are deliberately slow and resistant to GPU cracking
  • Timing-safe comparison is built into Spring's matches() via MessageDigest.isEqual — never implement it yourself
✦ Definition~90s read
What is Spring Security Password Encoding?

A PasswordEncoder in Spring Security is an interface with two methods: encode(rawPassword) which produces a one-way hash, and matches(rawPassword, encodedPassword) which verifies a candidate password against a stored hash without revealing the original. The interface deliberately separates encoding from matching to prevent naive implementations that decode instead of re-encode for comparison.

Storing passwords is like storing house keys.

BCryptPasswordEncoder is the most widely used implementation. BCrypt incorporates a random 128-bit salt into every hash output, making each stored hash unique even for identical passwords. The output is a 60-character string containing the version, cost factor, salt, and hash.

The strength parameter (4-31) sets 2^strength iterations — higher values exponentially increase computation time for both the application and an attacker.

DelegatingPasswordEncoder wraps multiple PasswordEncoder implementations and identifies them by string prefix. The default instance (created via PasswordEncoderFactories.createDelegatingPasswordEncoder()) encodes with BCrypt but can verify MD5, SHA-1, SHA-256, SCrypt, Argon2, and plaintext ({noop}) hashes.

This makes it the right default for every new Spring Boot application — you get modern encoding today with the flexibility to migrate later.

Plain-English First

Storing passwords is like storing house keys. BCrypt is like converting your key into a unique wax mold that can verify your key fits, but you can not copy the key back from the mold. MD5/SHA is like writing the key number on a sticky note — trivially reversible. DelegatingPasswordEncoder is a key cabinet that supports old mold formats while gradually upgrading them to the modern standard.

Your legacy application stores passwords as MD5 hashes. You know it is wrong, but you cannot force a million users to reset their passwords. A competitor gets breached; the MD5 hashes appear on HaveIBeenPwned within hours. Rainbow tables crack 95% of them in minutes. Now your incident response team is writing the breach notification email at 2 AM.

This scenario plays out repeatedly because password encoding is treated as a solved problem once a hash function is chosen. It is not. The threat model evolves: SHA-1 was considered strong, then MD5 became suspect, then SHA-256 without salt became crackable with GPU farms costing $10/hour on spot instances. The only lasting solution is using adaptive, deliberately slow algorithms and building a migration path into your codebase from day one.

Spring Security's PasswordEncoder interface and its implementations handle this correctly. BCryptPasswordEncoder applies a salt automatically, making rainbow tables useless. Its strength parameter controls the work factor — 12 iterations means 2^12 rounds of hashing, taking ~300ms on modern hardware. Attackers attempting brute force hit a wall; legitimate users barely notice.

DelegatingPasswordEncoder is the production migration tool. It stores the encoder id as a prefix ({bcrypt}, {sha256}, {noop}) alongside the hash. On verification, it routes to the correct encoder. On the next successful login, you upgrade the hash transparently — no password reset required. This is how you migrate from MD5 to BCrypt with zero user disruption.

Argon2 and scrypt take the arms race further. Designed specifically to resist GPU and ASIC attacks by requiring large amounts of memory, they are the recommended choice for new systems. Spring Security 5+ includes Argon2PasswordEncoder out of the box via Bouncy Castle.

This guide covers the full spectrum: BCrypt strength tuning for your hardware, DelegatingPasswordEncoder migration strategies, Argon2 and scrypt configuration, timing attack anatomy, and testing password encoding correctly.

BCryptPasswordEncoder: Strength, Salting, and Configuration

BCrypt was designed in 1999 specifically as a password hashing function. Unlike general-purpose hashes (SHA-256, MD5), BCrypt is slow by design. The strength parameter (also called cost or log rounds) determines how slow: each increment doubles the computation time. Strength 10 = 2^10 = 1024 iterations; strength 12 = 4096 iterations; strength 14 = 16,384 iterations.

On modern hardware, strength 12 takes approximately 250-350ms. This means an attacker testing passwords can attempt roughly 3 per second per CPU core — versus billions per second with raw SHA-256. GPU farms that crack SHA-256 at terahashes per second are throttled to thousands of attempts per second on BCrypt.

Every BCrypt hash includes a randomly generated 128-bit salt embedded in the output. This makes rainbow tables and precomputed attack tables useless — every hash is unique even for identical passwords. The full 60-character BCrypt output encodes the version (2a or 2b), the cost, the 22-character base64 salt, and the 31-character base64 hash.

Choose your strength based on benchmarking on the weakest server in your production fleet. The target: hashing should take 250-500ms. Too fast means attackers can brute-force efficiently; too slow degrades user experience during login. Strength 10 is the historical default; strength 12 is the current recommended minimum. Revisit annually as hardware gets faster.

Do not configure BCryptPasswordEncoder with a fixed SecureRandom unless you have a specific reason — the default uses SecureRandom correctly with system entropy. Passing a seeded SecureRandom reduces salt entropy and weakens the protection.

Never use strength < 10 in production
Strength 4-8 is suitable only for tests. Production minimum is strength 10; strength 12 is recommended. Re-evaluate annually as hardware improves — increment strength during off-peak hours with a rolling hash upgrade.
Production Insight
Store the BCrypt strength in configuration and plan for annual strength increments — implement UserDetailsPasswordService so strength upgrades happen transparently on next login.
Key Takeaway
BCrypt's strength parameter is a tuning dial, not a set-and-forget value — benchmark on your hardware, target 250-500ms, and implement automatic hash upgrade via UserDetailsPasswordService.

DelegatingPasswordEncoder for Zero-Downtime Migration

Legacy applications often store passwords as unsalted MD5, SHA-1, or SHA-256 hashes. Forcing a mass password reset is disruptive and drives user churn. DelegatingPasswordEncoder enables a gradual, transparent migration without any user interaction.

The delegation mechanism works via encoding prefixes stored in the database alongside the hash. The format is {encoderId}hash. When matches(raw, encoded) is called, the encoder extracts the id, finds the matching encoder, and delegates verification. When encoding new passwords (or upgrading on login), it always uses the current default encoder.

Migration path: first, update all existing MD5/SHA hashes in the database to add the appropriate prefix. MD5 becomes {MD5}d8578edf..., SHA-1 becomes {SHA-1}aaf4c61d.... No passwords change — only the prefix is added. Then deploy with DelegatingPasswordEncoder as the PasswordEncoder bean. Existing users continue to log in. On each successful login, Spring's DaoAuthenticationProvider detects the non-current encoding and calls UserDetailsPasswordService.updatePassword() with the new BCrypt hash. Over days or weeks, all active users migrate without any forced reset.

For inactive users who never log in again, consider a proactive migration job. Query for hashes with legacy prefixes, prompt users to reset their password, or after an extended period, lock accounts with legacy encodings.

The {noop} prefix supports plaintext passwords in development and tests. Never allow {noop} in production — add a Spring profile check or @PostConstruct validator that throws if {noop} encoding is active in non-development environments.

Prefix existing hashes before deploying DelegatingPasswordEncoder
Always run the SQL prefix migration BEFORE deploying the new encoder. If hashes have no prefix and you deploy DelegatingPasswordEncoder, all logins fail with IllegalArgumentException: no PasswordEncoder mapped for id null.
Production Insight
Track the distribution of password encoding prefixes in a Grafana dashboard — plot the daily count of {bcrypt} vs legacy prefixes to visualize migration progress and set a deadline for legacy account lockout.
Key Takeaway
DelegatingPasswordEncoder enables transparent, zero-downtime password migration — prefix existing hashes in the DB, implement UserDetailsPasswordService, and let Spring upgrade hashes on each login.

Argon2 and SCrypt: Memory-Hard Password Hashing

BCrypt was revolutionary in 1999, but modern GPU farms and ASICs challenge its assumptions. BCrypt requires minimal memory, making it parallelizable on GPU hardware. An NVIDIA RTX 4090 can attempt approximately 100,000 BCrypt-10 hashes per second — still slow, but ASIC farms can do much better.

Argon2 won the Password Hashing Competition in 2015 and is designed to resist GPU and ASIC attacks by requiring large amounts of memory. Its parameters control time (iterations), memory (KB), and parallelism (threads). To crack a single Argon2 hash, an attacker needs to allocate the configured memory per attempt — 64MB of memory per attempt makes massively parallel cracking economically unviable.

Argon2 has three variants: Argon2d (data-dependent memory access, resistant to GPU cracking), Argon2i (data-independent, resistant to side-channel attacks), and Argon2id (hybrid, recommended by RFC 9106 for password hashing). Spring Security's Argon2PasswordEncoder uses Argon2id by default.

SCrypt (implemented as SCryptPasswordEncoder in Spring Security) is another memory-hard function. It is an older alternative to Argon2 with similar goals but a more complex parameter space. Prefer Argon2 for new systems; SCrypt for compatibility with existing systems.

Choose parameters carefully: the OWASP recommendation for Argon2id is m=19456 (19 MB), t=2 iterations, p=1 thread for interactive authentication. Increase memory to m=65536 (64MB) for high-security applications where login latency can be 1-2 seconds.

Argon2 requires Bouncy Castle on the classpath
Add org.bouncycastle:bcprov-jdk18on to your pom.xml or build.gradle. Spring Boot's spring-security-crypto includes Argon2 support but delegates to Bouncy Castle at runtime.
Production Insight
Argon2's memory requirement is per-hash-operation — with m=65536 and 100 concurrent login requests, your JVM needs at least 6.5GB headroom. Set memory parameters based on your peak concurrency and available heap.
Key Takeaway
Argon2id with OWASP parameters is the modern recommendation for new systems; use DelegatingPasswordEncoder to support Argon2 for new hashes while BCrypt validates legacy ones.

Timing Attack Prevention: MessageDigest.isEqual and Spring's Mitigations

A timing attack exploits the fact that string comparison in most languages short-circuits on the first differing character. An attacker measuring response times can determine how many leading characters of their guess match the stored hash, iterating until the full hash is found. This is particularly relevant for HMAC signature verification and password comparison.

Java's String.equals() is not constant-time. MessageDigest.isEqual(byte[], byte[]) is — it always compares all bytes regardless of where the first mismatch occurs. Spring Security uses this internally in BCryptPasswordEncoder.matches() and other PasswordEncoder.matches() implementations.

However, timing attacks at the password comparison level are largely irrelevant for BCrypt because the attacker cannot observe which part of the BCrypt output matches. The more relevant timing vulnerability is the one described in the production incident above: the difference in time between the fast path (username not found → throw exception immediately) and the slow path (username found → run BCrypt).

Spring Security's DaoAuthenticationProvider mitigates this with a dummy password check when the user is not found. It calls passwordEncoder.matches(presentedPassword, userNotFoundEncodedPassword) where userNotFoundEncodedPassword is a pre-computed BCrypt hash of a random string. This ensures the authentication response takes approximately the same time whether the username exists or not.

For custom authentication code, always implement the same pattern: run the full password check before returning, use MessageDigest.isEqual for any HMAC or token comparison, and add random jitter (5-20ms) to authentication responses to prevent statistical timing analysis.

Never implement custom authentication bypassing DaoAuthenticationProvider
Custom authentication logic often omits the dummy BCrypt check for missing users. Always extend or configure DaoAuthenticationProvider rather than writing authentication from scratch — it has years of security hardening built in.
Production Insight
Add a @ControllerAdvice that introduces 5-50ms random jitter to all 401 responses — statistical timing analysis requires consistent timing, and jitter breaks the measurement.
Key Takeaway
Spring Security's DaoAuthenticationProvider already prevents username enumeration via timing — the risk is when custom authentication code bypasses it and returns early for missing users.

Password Policy Enforcement and Validation

A strong PasswordEncoder is necessary but not sufficient — weak passwords that are correctly BCrypt-hashed are still vulnerable to dictionary attacks. Password policy enforcement at the application layer prevents users from choosing easily guessable passwords.

Spring Security does not include a password policy engine, but Passay is the standard Java library for this. It validates passwords against configurable rules: minimum length, character class requirements, no common dictionary words, no username substring, no sequential characters. Integrate Passay in your UserService validation layer before calling passwordEncoder.encode().

For checking whether a password appears in known breach databases, integrate with the HaveIBeenPwned Passwords API. The API uses k-anonymity: you send the first 5 characters of the SHA-1 hash and receive all matching suffixes. This reveals nothing about the full password. Check this API asynchronously on password change and warn users whose passwords appear in breaches.

Password expiration is increasingly discouraged by NIST SP 800-63B (2017). Forced periodic expiration leads to weak increment patterns (Spring2023!Summer2023!). Instead, enforce expiration only when a breach is detected. Store the password encoding timestamp and flag accounts where the hash scheme is too old.

Store only the hash — never store the plaintext password even temporarily. Avoid logging request bodies for password-change endpoints. Mask sensitive fields in debug logs by configuring logging.level.org.springframework.security=DEBUG only in controlled environments with log scrubbing.

NIST recommends checking breach databases, not complexity rules
NIST SP 800-63B recommends checking passwords against known breach lists rather than enforcing character complexity rules. A long passphrase of dictionary words is stronger than a short P@ssw0rd that appears in breach databases.
Production Insight
Cache HaveIBeenPwned responses locally for the 5-character prefix — the same prefix may appear in many password change requests, and local caching reduces external API calls significantly.
Key Takeaway
Combine Passay policy validation with HaveIBeenPwned k-anonymity checks for production-grade password policy — enforce breach checks over character complexity rules per NIST SP 800-63B.
● Production incidentPOST-MORTEMseverity: high

Timing Attack Exposed Admin Password via Login Response Time Differential

Symptom
Security audit revealed that requests for non-existent usernames returned 401 in ~1ms while requests for valid usernames returned 401 in ~280ms — the BCrypt computation time was leaking existence of accounts.
Assumption
The team assumed BCrypt was sufficient protection. The response time differential was assumed negligible.
Root cause
The UserDetailsService threw UsernameNotFoundException immediately for unknown users, bypassing BCrypt entirely. For valid users, BCrypt ran its 2^12 iterations. The 280ms difference was measurable and consistent, enabling username enumeration via timing.
Fix
Added a dummy BCryptPasswordEncoder.matches() call in the exception handler for unknown users, ensuring constant-time response regardless of username validity. Spring Security actually handles this via DaoAuthenticationProvider.mitigateAgainstTimingAttack — enabled by configuring setUserDetailsPasswordService.
Key lesson
  • Timing attacks are real and exploitable remotely over HTTPS.
  • Always ensure authentication paths take the same time for valid and invalid credentials.
  • Spring Security's DaoAuthenticationProvider already mitigates this if configured correctly — do not bypass it with custom authentication logic.
Production debug guideSymptom → root cause → fix5 entries
Symptom · 01
Login fails with correct password after migration
Fix
Verify the stored hash has the correct {bcrypt} prefix if using DelegatingPasswordEncoder. Check that the hash was encoded with passwordEncoder.encode() not a raw BCrypt library call without prefix. Print the stored hash and verify it starts with {bcrypt}$2a$ or {bcrypt}$2b$. If missing prefix, update the stored value or switch to the bare BCryptPasswordEncoder (not DelegatingPasswordEncoder).
Symptom · 02
Password encoding is extremely slow in tests
Fix
BCrypt strength 12 takes ~300ms. In tests, use new BCryptPasswordEncoder(4) or new BCryptPasswordEncoder() with strength 4 for dramatically faster test execution. Never use strength 4 in production. Configure the encoder as a bean and mock or override it in test contexts using @TestConfiguration.
Symptom · 03
IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
Fix
This means the stored password has no {id} prefix but you are using DelegatingPasswordEncoder. Either prefix existing hashes with {bcrypt} in the database, or switch to a bare BCryptPasswordEncoder bean. If migrating, wrap the default encoder: PasswordEncoderFactories.createDelegatingPasswordEncoder() and update all existing hashes to have prefixes.
Symptom · 04
BCrypt hash does not match despite correct password
Fix
Confirm you are not double-encoding. If the UserDetailsService already returns the stored encoded password and Spring's DaoAuthenticationProvider calls matches(rawPassword, storedEncoded), do not call encode() again in your service. Also check character encoding — a password with non-ASCII characters may be encoded differently depending on the charset used in the HTTP request vs. the database.
Symptom · 05
Password upgrade (re-encoding) not happening on login
Fix
Implement UserDetailsPasswordService in your UserDetailsService. Spring Security calls updatePassword(user, newEncodedPassword) automatically when DelegatingPasswordEncoder detects an outdated encoding scheme. Without this implementation, hash upgrades never persist to the database.
★ Debug Cheat SheetFast commands for diagnosing password encoding issues.
Need to generate BCrypt hash for testing
Immediate action
Generate hash via Spring Boot CLI or curl
Commands
curl -s -X POST http://localhost:8080/actuator/env | grep -i password
java -cp spring-security-crypto.jar org.springframework.security.crypto.bcrypt.BCrypt -hashpw 'mypassword'
Fix now
Or in code: new BCryptPasswordEncoder(12).encode("testpassword")
Verify stored hash matches password+
Immediate action
Test encode/matches in a one-liner
Commands
grep -r 'BCryptPasswordEncoder\|PasswordEncoderFactories' src/main/java/
mvn test -pl . -Dtest=PasswordEncoderTest -q
Fix now
Add unit test: assertTrue(encoder.matches("rawPw", storedHash))
Check what encoding scheme is stored in DB+
Immediate action
Query the password column and inspect prefix
Commands
psql -U appuser -c "SELECT substring(password, 1, 10) as prefix, count(*) FROM users GROUP BY prefix;"
mysql -u root -p -e "SELECT LEFT(password,10) AS prefix, COUNT(*) FROM users.users GROUP BY prefix;"
Fix now
Hashes with no prefix or {noop} need migration to {bcrypt}
Test BCrypt strength vs latency tradeoff+
Immediate action
Benchmark locally to choose strength for hardware
Commands
for i in 10 11 12 13; do echo -n "Strength $i: "; time java -e "new BCryptPasswordEncoder($i).encode('test')" 2>&1 | grep real; done
mvn spring-boot:run -Dspring-boot.run.arguments='--bcrypt.strength=12' & curl -w '%{time_total}' -X POST localhost:8080/login -d 'username=u&password=p'
Fix now
Choose the highest strength that keeps login under 500ms on your weakest production node
Password Hashing Algorithm Comparison
AlgorithmGPU ResistanceMemory HardSpring SupportRec. for New Systems
MD5None (terahash/s)NoLegacy via DelegatingNever
SHA-256Poor (gigahash/s)NoLegacy via DelegatingNever
BCrypt-10ModerateNoBCryptPasswordEncoderMinimum
BCrypt-12GoodNoBCryptPasswordEncoderYes
SCryptGoodYesSCryptPasswordEncoderYes
Argon2idExcellentYesArgon2PasswordEncoderPreferred
PBKDF2ModerateNoPbkdf2PasswordEncoderAcceptable

Key takeaways

1
BCrypt (strength ≥ 12) and Argon2id are the only acceptable password hashing algorithms for production
never use MD5, SHA-1, or SHA-256 alone.
2
DelegatingPasswordEncoder enables zero-downtime migration from legacy hash algorithms; prefix existing hashes before deploying and implement UserDetailsPasswordService.
3
Spring Security's DaoAuthenticationProvider already prevents timing-based username enumeration
never bypass it with custom authentication logic.
4
Use MessageDigest.isEqual() for any security-sensitive string comparison; String.equals() leaks timing information.
5
Validate passwords against HaveIBeenPwned (k-anonymity) on registration and password change
breach exposure is a stronger security signal than character complexity rules.

Common mistakes to avoid

6 patterns
×

Comparing raw passwords with `equals()` instead of `passwordEncoder.matches()`

Symptom
Authentication works only when passwords are stored as plaintext; fails after introducing hashing
Fix
Always use passwordEncoder.matches(rawPassword, storedEncodedPassword). Never compare raw passwords. Never decode stored hashes.
×

Using `NoOpPasswordEncoder` in production (forgotten from a tutorial)

Symptom
Passwords stored as plaintext; any DB read reveals all credentials
Fix
Add a @PostConstruct validator that throws IllegalStateException if the active PasswordEncoder is NoOpPasswordEncoder and the profile is not dev or test.
×

Setting BCrypt strength too low (4-8) for performance in production

Symptom
Hashing is fast but provides minimal protection against brute force attacks
Fix
Use strength 12 as the production minimum. Benchmark on your hardware targeting 250-500ms per hash. Never sacrifice security for sub-100ms hashing.
×

Not implementing `UserDetailsPasswordService` when using `DelegatingPasswordEncoder`

Symptom
Hash upgrades detected on login but never persisted — old encoding remains in DB forever
Fix
Implement UserDetailsPasswordService.updatePassword() in your UserDetailsService and persist the new encoded password to the database.
×

Logging the raw password or the encoded password in debug output

Symptom
Passwords or password hashes appear in log files, increasing breach impact
Fix
Never log password-related fields. Mask sensitive fields in Spring Security debug logs. Add a custom LoggingFilter that sanitizes request bodies for /login and /password endpoints.
×

Deploying `DelegatingPasswordEncoder` without first prefixing existing hashes

Symptom
IllegalArgumentException: There is no PasswordEncoder mapped for the id "null" — all logins fail
Fix
Run the SQL migration to add {bcrypt} or {MD5} prefixes to existing hashes BEFORE deploying the new encoder. Test in staging with a copy of production data.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why should you never store passwords as MD5 or SHA-256 hashes?
Q02JUNIOR
What does the BCrypt strength parameter control, and how do you choose i...
Q03SENIOR
How does DelegatingPasswordEncoder enable zero-downtime migration from M...
Q04SENIOR
Explain a timing attack on a login endpoint and how Spring Security prev...
Q05SENIOR
What is the difference between BCrypt and Argon2 in terms of attack resi...
Q06SENIOR
How would you use the HaveIBeenPwned API without revealing the user's pa...
Q07SENIOR
Why is `MessageDigest.isEqual()` preferred over `String.equals()` for se...
Q08SENIOR
How do you handle the Argon2 memory requirement in a high-concurrency AP...
Q01 of 08JUNIOR

Why should you never store passwords as MD5 or SHA-256 hashes?

ANSWER
MD5 and SHA-256 are general-purpose hashes designed to be fast. An attacker with a GPU farm can compute billions of SHA-256 hashes per second, enabling exhaustive dictionary and brute-force attacks. They also lack salting by default, making precomputed rainbow tables effective. BCrypt and Argon2 are intentionally slow and include automatic salting.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can I use the same PasswordEncoder bean for all services in a microservice architecture?
02
How do I handle password encoding in a multi-tenant application where tenants may require different policies?
03
Is it safe to use BCrypt for hashing API keys or tokens?
04
How do I migrate passwords when users are offline for months and I need to deprecate old encodings?
05
Should I pepper passwords in addition to salting (BCrypt)?
06
What is the risk of using {noop} prefix in a shared configuration file?
🔥

That's Spring Security. Mark it forged?

7 min read · try the examples if you haven't

Previous
Deadlock in Java — Causes and Prevention
1 / 4 · Spring Security
Next
OAuth2 with Spring Security