Argon2id Configuration in Spring Security: The Parameters That Actually Matter
Argon2id Configuration in Spring Security
The Assumption
Most Spring Security tutorials show new BCryptPasswordEncoder() with no arguments, accepting whatever default work factor the library ships with. The implicit assumption: the defaults are secure enough.
They are not. Defaults are chosen for compatibility across hardware profiles, not for your specific threat model. The BCryptPasswordEncoder default work factor is 10, which produces a hash in roughly 100ms on modern server hardware. On a dedicated GPU rig (which an attacker controls), the same work factor produces a hash in under 1ms. A password database with work factor 10 falls to a dictionary attack in hours, not months.
Argon2id addresses this by making memory the bottleneck. GPUs have many cores but limited memory per core. An Argon2id configuration requiring 64MB per hash evaluation means each GPU core can only evaluate one hash at a time in its available memory, destroying the parallelism advantage that makes GPU attacks viable against bcrypt.
The Attack
Offline dictionary attack with GPU acceleration. The attacker obtains your credential database through any exfiltration vector: SQL injection, backup theft, compromised admin access. The attacker takes the hashed passwords offline to their own hardware. No rate limiting applies. No account lockout applies. No monitoring detects the attack. The attacker runs a dictionary of billions of known passwords (from previous breaches) against each hash.
With bcrypt work factor 10: approximately 5,000 hashes per second on a single RTX 4090. A database of 100,000 users yields roughly 20% cracked passwords within 24 hours (the 20% who used passwords from known breach lists).
With Argon2id (64MB memory, 3 iterations, 1 parallelism): approximately 4 hashes per second on the same GPU. The same 24-hour attack cracks approximately 0.003% of passwords. The memory requirement means the GPU’s 24GB VRAM can only evaluate 375 concurrent hashes (24GB / 64MB), compared to thousands of concurrent bcrypt evaluations.
The Spec or Mechanism
Argon2 was designed by Alex Biryukov, Daniel Dinu, and Dmitry Khovratovich. It won the Password Hashing Competition. Three variants exist:
- Argon2d: Data-dependent memory access pattern. Resistant to GPU attacks but vulnerable to side-channel attacks (timing attacks that observe memory access patterns). Suitable for cryptocurrency mining. Not suitable for server-side password hashing where side channels exist.
- Argon2i: Data-independent memory access pattern. Resistant to side-channel attacks but less resistant to GPU attacks because the memory access pattern is predictable.
- Argon2id: Hybrid. First pass uses data-independent access (resistant to side channels), subsequent passes use data-dependent access (resistant to GPU attacks). The recommended variant for password hashing.
Three parameters control the cost:
- Memory (m): Bytes of RAM required per hash evaluation. OWASP recommends minimum 47104 KiB (46 MB) for interactive logins and 2 GiB for offline/batch operations.
- Iterations (t): Number of passes over the memory. Each iteration adds CPU cost linearly. Minimum 1 for high memory configurations, minimum 3 for lower memory configurations.
- Parallelism (p): Number of threads used for hash computation. Affects the memory layout (memory is divided into p lanes). Set to the number of cores available to the hashing thread pool. Higher p with fixed m reduces memory per lane, which can reduce GPU resistance. Typical value: 1 for server-side hashing.
The interaction between these parameters matters: doubling memory (m) is more effective than doubling iterations (t) for GPU resistance, because memory is the scarce resource on GPUs.
The Implementation
// VULNERABLE: Default PasswordEncoder with no explicit parameters
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // Work factor 10, GPU-friendly
}
// HARDENED: Argon2id with production-appropriate parameters
@Bean
public PasswordEncoder passwordEncoder() {
return new Argon2PasswordEncoder(
16, // salt length in bytes
32, // hash length in bytes
1, // parallelism (lanes)
65536, // memory in KiB (64 MB)
3 // iterations
);
}
The parameters explained:
- Salt length (16 bytes): Ensures unique hashes for identical passwords. 16 bytes provides 128 bits of randomness, sufficient to make rainbow table attacks infeasible.
- Hash length (32 bytes): The output hash size. 32 bytes (256 bits) matches the security level of the underlying function.
- Parallelism (1): Single-threaded hashing on the server. Each login request blocks one thread for the hash duration. With parallelism=1 and memory=64MB, each concurrent authentication consumes 64MB of RAM. Plan thread pool size accordingly.
- Memory (65536 KiB = 64 MB): The critical parameter for GPU resistance. With 24GB GPU VRAM, the attacker can evaluate at most 375 concurrent hashes. With 128MB per hash, that drops to 187.
- Iterations (3): Three passes over the 64MB memory. Adds approximately 3x CPU cost compared to a single pass. Compensates for memory configurations below the 1GB threshold.
Tuning for Your Hardware
The target is 250ms to 1000ms per hash evaluation on your server hardware. Measure it:
@Component
public class PasswordEncoderBenchmark implements CommandLineRunner {
private final PasswordEncoder encoder;
public PasswordEncoderBenchmark(PasswordEncoder encoder) {
this.encoder = encoder;
}
@Override
public void run(String... args) {
String password = "benchmark-password-with-realistic-length-32ch";
// Warm up
encoder.encode(password);
// Measure
long start = System.nanoTime();
int iterations = 10;
for (int i = 0; i < iterations; i++) {
encoder.encode(password);
}
long elapsed = System.nanoTime() - start;
System.out.printf("Average hash time: %d ms%n",
TimeUnit.NANOSECONDS.toMillis(elapsed / iterations));
}
}
If the result is below 250ms, increase memory or iterations. If above 1000ms, decrease. The acceptable range depends on your login rate and thread pool capacity. A system handling 100 logins per second with 250ms per hash needs at least 25 threads dedicated to authentication.
Memory Planning
Each concurrent authentication attempt allocates the configured memory amount. With 64MB per hash and a 20-thread authentication pool, peak authentication memory consumption is 1.28 GB. This is memory that cannot be shared with the application heap. Plan your container memory limits accordingly:
Container memory = JVM heap + authentication_threads × argon2_memory + overhead
= 2GB + 20 × 64MB + 512MB
= 2GB + 1.28GB + 512MB
= 3.79GB → allocate 4GB
DelegatingPasswordEncoder Integration
If you have existing password hashes in a different format, use DelegatingPasswordEncoder to support multiple encoders simultaneously:
@Bean
public PasswordEncoder passwordEncoder() {
String defaultEncoderId = "argon2id";
Map<String, PasswordEncoder> encoders = Map.of(
"argon2id", new Argon2PasswordEncoder(16, 32, 1, 65536, 3),
"bcrypt", new BCryptPasswordEncoder(12),
"scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8()
);
return new DelegatingPasswordEncoder(defaultEncoderId, encoders);
}
New passwords hash with Argon2id. Existing bcrypt hashes (prefixed {bcrypt}) validate against the bcrypt encoder. On successful login with a non-Argon2id hash, re-hash with Argon2id and update the stored hash. This migrates users transparently without forcing password resets.
The Verification
@SpringBootTest
class Argon2ConfigurationTest {
@Autowired
private PasswordEncoder passwordEncoder;
@Test
void hashTimeWithinAcceptableRange() {
String password = "user-chosen-password-123!";
long start = System.nanoTime();
String hash = passwordEncoder.encode(password);
long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
assertThat(elapsed).isBetween(200L, 1200L);
assertThat(hash).startsWith("{argon2id}");
}
@Test
void samePasswordProducesDifferentHashes() {
String password = "user-chosen-password-123!";
String hash1 = passwordEncoder.encode(password);
String hash2 = passwordEncoder.encode(password);
assertThat(hash1).isNotEqualTo(hash2); // Different salts
assertThat(passwordEncoder.matches(password, hash1)).isTrue();
assertThat(passwordEncoder.matches(password, hash2)).isTrue();
}
@Test
void wrongPasswordRejectedInConstantTime() {
String password = "correct-password";
String hash = passwordEncoder.encode(password);
// Measure correct password timing
long correctStart = System.nanoTime();
passwordEncoder.matches(password, hash);
long correctTime = System.nanoTime() - correctStart;
// Measure incorrect password timing
long incorrectStart = System.nanoTime();
passwordEncoder.matches("wrong-password", hash);
long incorrectTime = System.nanoTime() - incorrectStart;
// Times should be similar (within 20% of each other)
// Constant-time comparison prevents timing side-channel
double ratio = (double) correctTime / incorrectTime;
assertThat(ratio).isBetween(0.7, 1.4);
}
}
The third test verifies timing attack resistance. If matches() returned early on the first byte mismatch, an attacker could determine how many bytes of the hash matched by measuring response time. Argon2 (and bcrypt) implementations use constant-time comparison for the final hash check, making both correct and incorrect password validation take approximately the same time.