Software Securityhome
Fix a findingSearch
Fix card · A04:2025 Cryptographic Failures

Fix weak password hashing in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Use Spring Security's DelegatingPasswordEncoder with an Argon2PasswordEncoder built from explicit parameters, new Argon2PasswordEncoder(16, 32, 1, 19456, 2), because defaultsForSpringSecurity_v5_8() is below OWASP's minimum. Read old SHA-256 rows with setDefaultPasswordEncoderForMatches(new MessageDigestPasswordEncoder("SHA-256")), and after matches() succeeds, re-encode whenever upgradeEncoding() is true.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC) with Spring Security Crypto 7.1.1
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks7 cards
Download the project ZIP, 25 KB
On this page

Steps

  1. Find every place that hashes or compares passwords: MessageDigest on a password, Pbkdf2PasswordEncoder or BCryptPasswordEncoder with old settings, NoOpPasswordEncoder, and String.equals or Arrays.equals between a computed and a stored hash.
  2. Add spring-security-crypto (Spring Boot manages its version) and org.bouncycastle:bcprov-jdk18on (pin it yourself), then build a DelegatingPasswordEncoder whose idForEncode maps to new Argon2PasswordEncoder(16, 32, 1, 19456, 2); memory is in KiB.
  3. Keep every format you have ever stored readable: put each old {id} in the map, and give rows without a prefix to setDefaultPasswordEncoderForMatches, here MessageDigestPasswordEncoder("SHA-256") for unsalted hex.
  4. On login, call matches(raw, stored); only if it returns true and upgradeEncoding(stored) is true, encode the password again and save it with UPDATE ... WHERE id = ? AND password_hash = <the hash you verified>.
  5. When the username is unknown, run a match against a dummy hash anyway and return the same 401, so neither the body nor the timing shows which accounts exist.
  6. If you use Spring Security's authentication, let DaoAuthenticationProvider do the upgrade: expose a UserDetailsPasswordService that persists the new hash, and use the same DelegatingPasswordEncoder bean.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/passwords/impl/VulnerablePasswordHashing.javaJava
@Override
public String hash(String password) {
    try {
        byte[] digest = MessageDigest.getInstance("SHA-256").digest(password.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(digest);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException(e);
    }
}

With IMPL=vulnerable, 4 of 11 tests fail (4 exploit tests).

The fix: hashing policy

Fixed…/passwords/impl/FixedPasswordHashing.javaJava
// OWASP minimum for Argon2id: 19 MiB (19456 KiB), 2 iterations, 1 lane; 16-byte salt,
// 32-byte hash. Spring's own defaultsForSpringSecurity_v5_8() is 16 MiB and 2 iterations,
// below that minimum, so pass every parameter explicitly.
private static final Argon2PasswordEncoder ARGON2 = new Argon2PasswordEncoder(16, 32, 1, 19_456, 2);

private final DelegatingPasswordEncoder encoder = legacyAwareEncoder();

@SuppressWarnings("deprecation") // MessageDigestPasswordEncoder only reads old rows, never writes them
private static DelegatingPasswordEncoder legacyAwareEncoder() {
    // New hashes are stored as {argon2}$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>.
    DelegatingPasswordEncoder encoder = new DelegatingPasswordEncoder("argon2", Map.of("argon2", ARGON2));
    // Rows from the old system have no {id} prefix: they are unsalted SHA-256 in hex.
    encoder.setDefaultPasswordEncoderForMatches(new MessageDigestPasswordEncoder("SHA-256"));
    return encoder;
}

@Override
public String hash(String password) {
    return encoder.encode(password);
}

With IMPL=fixed, 11 of 11 tests pass.

The fix: verify and upgrade

Fixed…/passwords/impl/FixedPasswordHashing.javaJava
@Override
public Verification verify(String password, String storedHash) {
    // Both encoders compare in constant time (MessageDigest.isEqual or an XOR loop).
    boolean valid = encoder.matches(password, storedHash);
    // upgradeEncoding() is true for every row without the {argon2} prefix, and for
    // Argon2 hashes with less memory or fewer iterations than ARGON2.
    return new Verification(valid, valid && encoder.upgradeEncoding(storedHash));
}

With IMPL=fixed, 11 of 11 tests pass.

The fix: write the upgrade at login

Fixed…/example/passwords/AccountController.javaJava
@PostMapping("/login")
ResponseEntity<Map<String, String>> login(@RequestBody Credentials credentials) {
    if (!credentials.isValid()) {
        return BAD_REQUEST;
    }
    Optional<StoredUser> user = users.findByUsername(credentials.username());
    Verification result = passwords.verify(
            credentials.password(), user.map(StoredUser::passwordHash).orElse(dummyHash));
    if (user.isEmpty() || !result.valid()) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("error", "invalid username or password"));
    }
    if (result.needsRehash()) {
        // Only now is the plaintext known to be right. Replace exactly the hash that
        // was verified: if a password change landed in the meantime, no row matches.
        users.replaceHash(user.get().id(), user.get().passwordHash(), passwords.hash(credentials.password()));
    }
    return ResponseEntity.ok(Map.of("username", credentials.username()));
}

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fix…/passwords/impl/WrongPasswordHashing.javaJava
private static final SecureRandom RANDOM = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();

@Override
public String hash(String password) {
    byte[] salt = new byte[16];
    RANDOM.nextBytes(salt);
    return "sha256$" + HEX.formatHex(salt) + "$" + HEX.formatHex(sha256(salt, password));
}

private static byte[] sha256(byte[] salt, String password) {
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        digest.update(salt);
        return digest.digest(password.getBytes(StandardCharsets.UTF_8));
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException(e);
    }
}

With IMPL=wrong, 3 of 11 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Test…/example/passwords/AccountApiTest.javaJava
// Exploit: what a stolen copy of the users table gives an attacker, and how hashes upgrade.

@Test
void exploitANewHashIsArgon2idAtOrAboveTheOwaspMinimum() throws Exception {
    assertThat(register("ivan", "ivan-has-a-long-passphrase")).isEqualTo(201);
    assertMeetsPolicy(storedHash("ivan"));
}

@Test
void exploitTheSamePasswordGivesEachUserADifferentHash() throws Exception {
    register("judy", LEGACY_PASSWORD);
    register("mallory", LEGACY_PASSWORD);
    assertThat(storedHash("judy")).isNotEqualTo(storedHash("mallory"));
}

@Test
void exploitALegacySha256HashIsReplacedAfterASuccessfulLogin() throws Exception {
    String legacy = storedHash("alice");
    assertThat(login("alice", LEGACY_PASSWORD)).isEqualTo(200);
    assertThat(storedHash("alice")).isNotEqualTo(legacy);
    assertMeetsPolicy(storedHash("alice"));
    assertThat(login("alice", LEGACY_PASSWORD)).as("the new hash verifies").isEqualTo(200);
}

@Test
void exploitAWrongPasswordIsRejectedAndLeavesTheLegacyHashAlone() throws Exception {
    String legacy = storedHash("alice");
    assertThat(login("alice", "correct horse battery stapler")).isEqualTo(401);
    assertThat(storedHash("alice")).isEqualTo(legacy);
}

@Test
void exploitAHashMadeWithSpringsDefaultArgon2SettingsIsRehashedAfterLogin() throws Exception {
    // defaultsForSpringSecurity_v5_8(): 16 MiB and 2 iterations, below the OWASP minimum.
    String weak = "{argon2}" + Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8().encode(LEGACY_PASSWORD);
    assertThat(weak).contains("$m=16384,t=2,p=1$");
    assertThat(meetsPolicy(weak)).isFalse();
    setStoredHash("bob", weak);
    assertThat(login("bob", LEGACY_PASSWORD)).isEqualTo(200);
    assertMeetsPolicy(storedHash("bob"));
}

Pitfalls

  • Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8() is 16 MiB (1 << 14 KiB), 2 iterations, parallelism 1, and the "argon2" id in PasswordEncoderFactories still means defaultsForSpringSecurity_v5_2(): 4 MiB, 3 iterations. Both are below OWASP's minimum, so construct the encoder with explicit parameters; a test in this card checks the v5_8 values.
  • Argon2PasswordEncoder needs Bouncy Castle on the classpath, and Spring Boot 4.1.1 does not manage its version, so pin org.bouncycastle:bcprov-jdk18on. Bouncy Castle's Argon2 is pure Java: one hash with this policy took about 20 ms here, after JIT warm-up.
  • Without setDefaultPasswordEncoderForMatches(), matches() throws IllegalArgumentException for a stored hash without a known {id} prefix. Hashes from PasswordEncoderFactories start with {bcrypt}, so keep "bcrypt" in the map if you ever used it.
  • upgradeEncoding() is true for any id other than idForEncode; for Argon2 it compares only memory and iterations (lower means upgrade), so a change of parallelism, salt length or hash length alone does not trigger a rehash.
  • MessageDigestPasswordEncoder is deprecated as insecure, but Spring keeps it for reading legacy hashes ('There are no plans to remove this support'). Use it only for matching, never as idForEncode; it compares with MessageDigest.isEqual, so the legacy check stays constant-time.
  • Spring Security 7.1.1's BCryptPasswordEncoder refuses to encode a password longer than 72 bytes (IllegalArgumentException), so a bcrypt policy needs a byte limit that users can understand; Argon2PasswordEncoder has no such limit.
  • Argon2PasswordEncoder.matches() uses the parameters stored in the hash, so a tampered row asking for gigabytes of memory costs that much on one login; bound m and t if anything other than this code can write the users table.
  • NIST SP 800-63B-4 recommends NFC normalization before hashing, while ASVS 5.0 (6.2.8) asks for the password to be verified exactly as received. This card hashes the UTF-8 bytes it receives (Bouncy Castle converts the password's chars to UTF-8); if you normalize, do it identically on every path.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs password-hashing/java-spring does this for you).
  2. Look at real rows: accounts created or used since the change should start with {argon2}$argon2id$v=19$m=19456,t=2,p=1$ (or stronger), and the number of rows without a { prefix should fall as people log in.
  3. Run Find Security Bugs for the timing-unsafe comparison (UNSAFE_HASH_EQUALS), but don't expect a scanner to report a fast hash: neither it nor the Semgrep rule above flags SHA-256, and CodeQL's Java check for unsalted hashes is experimental. The regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix weak password hashing in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.lang.security.audit.md5-used-as-password.md5-used-as-passwordNo findings on this card. The rule needs MessageDigest.getInstance("MD5") assigned to a variable and its digest() passed to a method named like password; it found nothing even with MD5 swapped into VulnerablePasswordHashing (the call is chained), and reported a sample written in that shape.Checked: Semgrep 1.180.0 (CE)
CodeQLjava/hash-without-saltExperimental, so not in the default suites; read from the source, not run. It flags a variable named like password that reaches a single MessageDigest.digest() or update() call, which matches VulnerablePasswordHashing.hash. WrongPasswordHashing calls update(salt) and then digest(password) on the same object, which the query treats as salted, and nothing in it or in the standard Java queries checks the work factor.Checked: github/codeql main at b5b8165; not executed
Find Security Bugs (SpotBugs plugin)UNSAFE_HASH_EQUALSUnsafe hash equalsReported VulnerablePasswordHashing.verify, where String.equals compares the hashes; nothing in the fixed or wrong code. No pattern reported the fast hash itself: WEAK_MESSAGE_DIGEST_MD5 and WEAK_MESSAGE_DIGEST_SHA1 do not cover SHA-256. The only other reports were informational SPRING_ENDPOINT notes on the controller.Checked: Find Security Bugs 1.14.0 on SpotBugs 4.10.4 (spotbugs-maven-plugin 4.10.4.1, effort Max, threshold Low)

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC) with Spring Security Crypto 7.1.1
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.security:spring-security-crypto (managed by Spring Boot)7.1.1
org.bouncycastle:bcprov-jdk18on (required by Argon2PasswordEncoder, not managed by Spring Boot)1.86
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-jdbc4.1.1
org.xerial:sqlite-jdbc3.53.4.0 (SQLite 3.53.4)
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the password-hashing/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs password-hashing/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 25 KB

Verified

Machine
Apple M5 Max, macOS 27.0
Toolchain
  • java OpenJDK 21.0.12.1
  • maven 3.9.16
Matrix
fixed: 11 of 11 tests pass; vulnerable: 4 of 11 tests fail (4 exploit tests); wrong: 3 of 11 tests fail (3 exploit tests)
Test results
Offline run of run-all.mjs (outbound network blocked): fixed 11/11 pass; vulnerable 4/11 fail (4 exploit); wrong 3/11 fail (3 exploit)
Hash cost
20.4 ms per Argon2id hash at m=19456, t=2, p=1 with Bouncy Castle 1.86 (mean of 10 after 10 warm-up hashes)

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Password Storage Cheat Sheet
  2. Spring Security: Password Storage (DelegatingPasswordEncoder, Argon2PasswordEncoder)
  3. Spring Security API: Argon2PasswordEncoder
  4. Spring Security API: DelegatingPasswordEncoder
  5. Spring Security source: Argon2PasswordEncoder.java
  6. Spring Security: DaoAuthenticationProvider and password upgrades
  7. RFC 9106: Argon2 (parameter choice)
  8. NIST SP 800-63B-4: Password verifiers
  9. Find Security Bugs: bug patterns

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.