ADR-0004: Argon2id password hashing with transparent upgrade from BCrypt
Status: Accepted (retroactive) Date: 2026-07-24
Context
The system originally hashed passwords with BCrypt, and real user rows with BCrypt hashes existed in every deployed database. Two problems:
- BCrypt is CPU-hard but memory-cheap, so GPU/ASIC cracking rigs scale against it; OWASP's current first choice is Argon2id.
- A hash-algorithm change must not require resetting anyone's password — forcing resets across per-client production stacks (ADR-0007) for a purely internal crypto upgrade would be user-hostile and operationally noisy.
A password hash can only be recomputed when the plaintext is available — i.e. at login. Any migration therefore has to be lazy.
Decision
Implemented in
PasswordEncoderConfiguration:
- Argon2id becomes the default encoder:
new Argon2PasswordEncoder(16, 32, 1, 19456, 2)— 16-byte salt, 32-byte hash, parallelism 1, 19456 KiB (~19 MiB) memory, 2 iterations — the OWASP-recommended parameter set. - Wrapped in Spring Security's
DelegatingPasswordEncoderwithargon2as the default id and BCrypt registered as a legacy delegate, so{bcrypt}...hashes still verify. - Transparent rehash on login:
upgradeEncoding()reports true for BCrypt hashes, and the login flow re-encodes the just-verified plaintext as{argon2}and saves it. No forced resets, no batch migration, no user-visible change. (Documented in security/request-pipeline.md, "Password Security".)
Consequences
Positive
- New and active users end up on a memory-hard hash with zero support burden — the fleet converges to Argon2id one login at a time.
- The
{id}prefix scheme makes any future algorithm migration (e.g. tuning Argon2 parameters) the same trivial pattern again. - No flag-day: old and new hashes coexist indefinitely and both verify.
Negative
- Dormant accounts keep BCrypt hashes forever — the weakest hashes belong to exactly the accounts nobody is watching. A DB leak exposes those rows at BCrypt strength until (unless) the user logs in again.
- ~19 MiB of memory per login verification. A burst of concurrent logins multiplies that; this is one reason login is rate-limited (ADR-0006) — the hash itself is a resource-exhaustion vector.
- The saving write on login makes authentication a read-write operation, which is easy to forget when reasoning about "read-only" login load.
References
../../backend/src/main/java/com/motorph/payroll/config/PasswordEncoderConfiguration.java../security/request-pipeline.md— "Password Security" section../security/authentication.md— login flow- 0006-bucket4j-inmemory-ratelimit.md — why expensive hashing needs a rate limiter in front