Spring Security Password Encoding: BCrypt, Argon2 & DelegatingPasswordEncoder
Master Spring Security password encoding with BCryptPasswordEncoder, DelegatingPasswordEncoder migration from MD5/SHA, Argon2, and timing attack prevention.
- 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
DelegatingPasswordEncoderto 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()viaMessageDigest.isEqual— never implement it yourself
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.
UserDetailsPasswordService so strength upgrades happen transparently on next login.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.
DelegatingPasswordEncoder, all logins fail with IllegalArgumentException: no PasswordEncoder mapped for id null.{bcrypt} vs legacy prefixes to visualize migration progress and set a deadline for legacy account lockout.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.
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.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.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 B and other CryptPasswordEncoder.matches()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.
DaoAuthenticationProvider rather than writing authentication from scratch — it has years of security hardening built in.@ControllerAdvice that introduces 5-50ms random jitter to all 401 responses — statistical timing analysis requires consistent timing, and jitter breaks the measurement.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.
P@ssw0rd that appears in breach databases.Timing Attack Exposed Admin Password via Login Response Time Differential
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.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.- 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
DaoAuthenticationProvideralready mitigates this if configured correctly — do not bypass it with custom authentication logic.
{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).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.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"{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.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.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.curl -s -X POST http://localhost:8080/actuator/env | grep -i passwordjava -cp spring-security-crypto.jar org.springframework.security.crypto.bcrypt.BCrypt -hashpw 'mypassword'new BCryptPasswordEncoder(12).encode("testpassword")Key takeaways
DelegatingPasswordEncoder enables zero-downtime migration from legacy hash algorithms; prefix existing hashes before deploying and implement UserDetailsPasswordService.DaoAuthenticationProvider already prevents timing-based username enumerationMessageDigest.isEqual() for any security-sensitive string comparison; String.equals() leaks timing information.Common mistakes to avoid
6 patternsComparing raw passwords with `equals()` instead of `passwordEncoder.matches()`
passwordEncoder.matches(rawPassword, storedEncodedPassword). Never compare raw passwords. Never decode stored hashes.Using `NoOpPasswordEncoder` in production (forgotten from a tutorial)
@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
Not implementing `UserDetailsPasswordService` when using `DelegatingPasswordEncoder`
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
LoggingFilter that sanitizes request bodies for /login and /password endpoints.Deploying `DelegatingPasswordEncoder` without first prefixing existing hashes
IllegalArgumentException: There is no PasswordEncoder mapped for the id "null" — all logins fail{bcrypt} or {MD5} prefixes to existing hashes BEFORE deploying the new encoder. Test in staging with a copy of production data.Interview Questions on This Topic
Why should you never store passwords as MD5 or SHA-256 hashes?
Frequently Asked Questions
That's Spring Security. Mark it forged?
7 min read · try the examples if you haven't