Password Hashing in Java: BCrypt vs Argon2 vs PBKDF2 with Spring Security 7
Measured timings for BCrypt, PBKDF2, Argon2id and scrypt on Spring Security 7.1.1, how DelegatingPasswordEncoder upgrades old hashes on login, the BCrypt 72-byte trap where matches() silently truncates, and a pepper that fixes it. Four runnable Java files to download.
You store a password hash so that a stolen database does not hand an attacker every user’s password. That sentence hides the whole problem: a hash that is fast is useless here, because an attacker with your database can try billions of guesses per second on a GPU. So password hashing is the one place in software where you want the algorithm to be slow, memory-hungry, and tunable.
This post measures the four algorithms Spring Security ships — BCrypt, PBKDF2, scrypt and Argon2id — on a real machine, then walks through the four things that actually go wrong in production: choosing the cost, migrating old hashes, BCrypt’s 72-byte limit (where Spring Security 7.1.1 has a surprising asymmetry), and adding a pepper. Every number below comes from a program you can download and run.
Versions tested. Spring Security spring-security-crypto 7.1.1 (GA; the Spring Boot 4.1.1 line, GA September 2026), spring-core 7.0.9, BouncyCastle bcprov-jdk18on 1.86 (needed by Argon2), JDK 25.0.4.1 LTS (Temurin). Versions were read from maven-metadata.xml on Maven Central on 17 October 2026. Timings were taken on a 2-vCPU cloud sandbox, so your milliseconds will differ — the ratios and the behaviours are what carry over.
One thing that cost a run while writing this:spring-security-crypto 7.1.1 declares spring-core as optional, but matches() calls org.springframework.util.StringUtils. Using the crypto module on its own, without Spring Boot, ends in NoClassDefFoundError on the first matches() call until you add spring-core yourself.
You want to…
Read
Pick a cost factor with real numbers
“A good hash is slow on purpose”
Support several algorithms at once
“DelegatingPasswordEncoder stores the algorithm in the hash”
Move users off an old hash without a reset email
“Upgrade the hash on login”
Understand the BCrypt 72-byte limit
“The 72-byte trap”
Add a server-side secret
“A pepper”
A good hash is slow on purpose
Think of a password hash as a tax on every guess. A login costs your server one hash, and you can afford a few hundred milliseconds. An attacker with a stolen table needs one hash per guess, so the same few hundred milliseconds multiplies into years once the password is not in a common-password list. Every algorithm below lets you dial that tax up; they differ in what they make expensive.
PBKDF2 burns CPU time only (a loop of HMACs). Cheap to run on GPUs, so it needs a very high iteration count. It is the one NIST has long approved.
BCrypt burns CPU with a small, awkward memory pattern (4 KB) that was hard for GPUs in 1999 and is still annoying. The cost factor is a power of two.
scrypt and Argon2id are memory-hard: each guess needs megabytes of RAM, which is what limits an attacker running thousands of guesses in parallel. Argon2id won the 2015 Password Hashing Competition.
HashTimings.java measures one hash per algorithm and setting. Its core is a warm-up call followed by five timed calls:
static void time(String label, PasswordEncoder enc) {
String pw = "correct horse battery staple";
enc.encode(pw); // warm-up so JIT does not distort the first number
int runs = 5;
long total = 0;
String last = null;
for (int i = 0; i < runs; i++) {
long t0 = System.nanoTime();
last = enc.encode(pw);
total += System.nanoTime() - t0;
}
System.out.printf("%-34s %7.1f ms hash length=%d%n", label, total / runs / 1e6, last.length());
}
BCrypt cost=10 77.2 ms hash length=60
BCrypt cost=11 148.6 ms hash length=60
BCrypt cost=12 301.3 ms hash length=60
BCrypt cost=13 606.6 ms hash length=60
BCrypt cost=14 1234.6 ms hash length=60
PBKDF2-HMAC-SHA256 310k iter 436.1 ms hash length=96
PBKDF2-HMAC-SHA256 600k iter 673.6 ms hash length=96
Argon2id m=16MiB t=2 p=1 58.1 ms hash length=97
Argon2id m=19MiB t=2 p=1 (OWASP) 70.8 ms hash length=97
Argon2id m=64MiB t=3 p=1 532.1 ms hash length=97
SCrypt N=2^15 r=8 p=1 186.7 ms hash length=76
The chart shows the one thing worth memorising about BCrypt: the cost is a power of two, so one extra step doubles both your login latency and the attacker’s cost. That also means the cost is a coarse knob — 12 gives 301 ms here and 13 gives 607 ms; there is nothing in between.
Do not copy parameters, copy the method. Look at the Argon2 rows. The commonly quoted OWASP-style setting (19 MiB, 2 iterations) took 71 ms here, far below a BCrypt cost-12 login, while 64 MiB with 3 iterations took 532 ms. The recommended minimums are floors for safety at a given memory size, not latency targets. Measure on the hardware that will serve logins, pick a latency budget you can afford under peak load (a few hundred milliseconds is typical), then raise the memory and iteration count until you reach it. Remember that every concurrent login holds that much memory: 64 MiB × 50 simultaneous logins is 3.2 GB.
Going deeper: dependencies and why Argon2 needs BouncyCastle
Spring Security’s Argon2PasswordEncoder delegates to BouncyCastle’s Argon2 implementation; it is not part of the JDK. BCrypt, PBKDF2 and scrypt live in spring-security-crypto (scrypt also uses BouncyCastle for its core). The minimal Maven dependencies used for every demo here:
In a Spring Boot 4.1 application you get spring-core and logging from the starters; you still add bcprov-jdk18on yourself for Argon2 or scrypt. Compile and run the downloads with javac -cp "lib/*" -d out *.java and java -cp "out:lib/*" HashTimings.
DelegatingPasswordEncoder stores the algorithm in the hash
Suppose you started with BCrypt in 2019 and want Argon2 today. You cannot change every stored hash at once — you do not have the plaintext. What you need is a stored hash that says which algorithm made it. That is exactly what DelegatingPasswordEncoder does: it writes {id} in front of every hash, and on matches() it reads the id and routes to the right encoder. New hashes use the default id; old hashes keep verifying under theirs.
DelegatingUpgrade.java builds one with three encoders and Argon2 as the default for new hashes:
static DelegatingPasswordEncoder encoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder(10));
encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
// new hashes use argon2; old ones still verify via their own prefix
return new DelegatingPasswordEncoder("argon2@SpringSecurity_v5_8", encoders);
}
The missing-prefix error. A hash with no {id} in front is rejected outright. From the same file’s output: IllegalArgumentException: Given that there is no default password encoder configured, each password must have a password encoding prefix. Please either prefix this password with '{noop}' or set a default password encoder in `DelegatingPasswordEncoder`. That is the fingerprint of importing a legacy table of raw BCrypt strings: either prefix them with {bcrypt} in a migration, or call setDefaultPasswordEncoderForMatches(...) as a stop-gap.
Upgrade the hash on login
The moment a user logs in, you hold their plaintext password for a few milliseconds — the only time you ever can re-hash it. DelegatingPasswordEncoder.upgradeEncoding(storedHash) answers “was this made by something other than my current default?”, so the pattern is: verify, check, re-hash, store.
static boolean login(DelegatingPasswordEncoder enc, String user, String password) {
String stored = db.get(user);
if (!enc.matches(password, stored)) {
return false;
}
if (enc.upgradeEncoding(stored)) { // stored hash uses an old algorithm / cost
db.put(user, enc.encode(password)); // re-hash now: we hold the plaintext only here
System.out.println(" -> upgraded stored hash for " + user);
}
return true;
}
Output of the demo: a user with a cost-10 BCrypt hash logs in once, wrongly then correctly (DelegatingUpgrade.java):
before login : {bcrypt}$2a$10$cBuXoPOlx6MejxmlKGKM2uKue...
wrong password -> false
-> upgraded stored hash for alice
good password -> true
after login : {argon2@SpringSecurity_v5_8}$argon2id$v=...
second login -> true (no upgrade message expected)
The failed login upgraded nothing — the check happens only after matches() succeeds, which is the right order. Users who never log in again keep their old hash; that is the honest limit of this technique, and the reason to also plan a deadline after which dormant accounts are forced through a reset.
Going deeper: letting Spring Security do the upgrade for you
You do not have to write the login() method above in a Spring application. As far as the reference documentation describes it (this part was not executed in the demos here), DaoAuthenticationProvider calls upgradeEncoding after a successful match and, if your UserDetailsService also implements UserDetailsPasswordService, hands the new hash to its updatePassword(user, newHash) method. Persisting the new hash is then your one method. The related post on migrating Spring Security 5 → 6 → 7 covers the other changes that touch authentication providers.
The 72-byte trap
BCrypt reads only the first 72 bytes of the password and ignores everything after. For years that was a silent truncation: a 100-character passphrase was effectively a 72-character one. Spring Security has since added a guard — but, as the run below shows on 7.1.1, the guard is not symmetric.
Bcrypt72ByteTrap.java hashes a 72-byte password, then tries a longer one in both directions:
72-byte password hashes fine: true
encode(>72 bytes) rejected: password cannot be more than 72 bytes
matches(>72 bytes against 72-byte hash)=true
emoji password: chars=38 utf8-bytes=76
emoji password rejected: password cannot be more than 72 bytes
Read the third line carefully, because it is the whole trap: encode() refuses a password longer than 72 bytes, yet matches() with a longer password against a 72-byte hash returns true. The extra characters are ignored on the verify path. Put differently, an old row created before the guard existed — or any 72-byte hash — will accept any password that starts with the same 72 bytes. The fourth and fifth lines show the other twist: the limit is in bytes, so 19 key emoji (38 characters, 76 UTF-8 bytes) are rejected even though they look short.
What this means for your login form. If users may choose long passphrases (password managers love generating them), a plain BCrypt setup will either reject them at sign-up or, for legacy hashes, compare only their first 72 bytes. The fix is not to cap the user at 72 and hope: pre-hash the password to a fixed short string first, which is exactly what the next section does.
A pepper: a secret the database does not contain
A salt is random per user and stored next to the hash (BCrypt and Argon2 do this for you). A pepper is one secret for the whole application, kept outside the database — in a secrets manager or an HSM. If the table leaks but the pepper does not, the attacker cannot even start guessing.
The classic way is to run the password through HMAC-SHA256(pepper, password) first and hash that. Base64 of a SHA-256 output is always 44 characters, so as a side effect it also removes the 72-byte problem. PepperedEncoder.java wraps any PasswordEncoder:
private String prehash(CharSequence raw) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(pepper, "HmacSHA256"));
byte[] out = mac.doFinal(raw.toString().getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(out);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@Override public String encode(CharSequence raw) { return delegate.encode(prehash(raw)); }
@Override public boolean matches(CharSequence raw, String stored) { return delegate.matches(prehash(raw), stored); }
100-char password with pepper: matches=true
same password, first 72 chars only: matches=false (truncation gone)
stolen DB + wrong pepper: matches=false
Three results, three jobs done (PepperedEncoder.java): a 100-character password verifies, its 72-character prefix no longer does, and an attacker who has the database but guesses the wrong pepper gets nothing.
The price of a pepper. You cannot rotate it casually: changing the pepper invalidates every stored hash, because the prehash output changes. A rotation scheme needs a version marker stored with each hash (for example a {pepper2} prefix handled by a delegating encoder) and the same upgrade-on-login migration as above. The demo here hard-codes the pepper in the source purely so the program is runnable — real peppers never live in code.
Going deeper: what FIPS mode allows (not executed here)
Everything in this section is sourced from standards and the sibling post, not from a run on a FIPS-validated JVM. As far as I know, PBKDF2 (NIST SP 800-132) is the password-based key derivation function that FIPS 140-3 modules approve, while BCrypt, scrypt and Argon2 are not on the approved list. If a contract or regulator requires FIPS mode, the practical consequence is: use PBKDF2-HMAC-SHA256 or higher with the highest iteration count you can afford (the 600k row above is one example), and do not expect to enable BCrypt inside a validated provider. The full walk-through of running Spring Boot under a FIPS provider is in FIPS 140-3 in Java with Oracle Jipher.
Which one should you pick?
Situation
Pick
Why
New system, no compliance constraint
Argon2id
Memory-hard; tune memory and time to your latency budget
Existing system, works today
Keep BCrypt, raise cost to the budget
Mature, no extra dependency; mind the 72-byte limit
FIPS or regulator-mandated
PBKDF2-HMAC-SHA256+
The approved choice; compensate with iterations
Mixed legacy estate
DelegatingPasswordEncoder
Prefix routes each hash; upgrade on login
Should you store passwords at all? Every algorithm above defends a database you hopefully never lose. If you can avoid passwords — passkeys, or delegating to an identity provider — do that instead. The post on passkeys and WebAuthn in Spring Security 7 shows the replacement, and JWT authentication in Spring Security 7.1 covers what happens after the password check succeeds.
Downloads
All four programs compile and run on JDK 25 with the dependencies listed in the accordion above:
No Comments yet!