Storing Passwords Correctly: bcrypt and argon2

1. Overview

Storing passwords incorrectly is one of the most common vulnerabilities that leads to user data exposure when a database is attacked. This article helps you understand why you shouldn’t use plain MD5/SHA256 to hash passwords, and how to use BCryptPasswordEncoder and Argon2PasswordEncoder in Spring Security. By the end, you’ll be able to configure a secure PasswordEncoder, write tests to verify behavior, and know which algorithm to choose for your project.

2. Why you shouldn’t hash passwords with plain SHA-256

Cryptographic hash functions like SHA-256 and SHA-512 are designed to run as fast as possible. This is great when checking file integrity, but it’s a disaster for passwords.

import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;

public class NaiveHasher {

    public static String sha256(String password) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
        return java.util.HexFormat.of().formatHex(hash);
    }
}

The function above can hash billions of passwords per second on a mid-range GPU. An attacker only needs a rainbow table or brute force to recover the original password, especially for common passwords like 123456 or password1.

Two core problems:

  • No salt: two users with the same password produce the same hash, making them vulnerable to rainbow table attacks.
  • Too fast: there’s no way to “slow down” an attacker.

Note: Don’t manually add salt and concatenate it to SHA-256. That’s still weak because it lacks “work factor” — the computational difficulty doesn’t increase as hardware gets more powerful.

3. What is a password hashing function

Algorithms like bcrypt, scrypt, and argon2 are designed specifically for password hashing, with three characteristics:

  1. Automatic salt: each time you hash, a random salt is generated and embedded in the output hash string.
  2. Intentionally slow (work factor): parameters let you increase difficulty over time to resist brute-force as hardware improves.
  3. Memory-hard (with argon2/scrypt): consumes lots of RAM, making parallel execution on GPUs/ASICs less effective.

A bcrypt hash string looks like this:

$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW

Structure: $<version>$<cost>$<salt+hash base64>. Here 10 is the cost factor (2^10 iterations).

4. Using bcrypt with Spring Security

4.1. Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

BCryptPasswordEncoder is already included in spring-security-crypto, no need to add anything else.

4.2. Bean declaration

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12); // cost factor = 12
    }
}

The parameter 12 determines the number of iterations is 2^12. Higher cost factor → slower → more secure, but also uses more CPU when logging in. A value of 10–12 is reasonable for most applications in 2026.

4.3. Using during registration and login

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserRegistrationService {

    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;

    public UserRegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
        this.passwordEncoder = passwordEncoder;
        this.userRepository = userRepository;
    }

    public void register(String email, String rawPassword) {
        String hashed = passwordEncoder.encode(rawPassword);
        userRepository.save(new Customer(email, hashed));
    }

    public boolean authenticate(String email, String rawPassword) {
        Customer customer = userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("Email does not exist"));
        return passwordEncoder.matches(rawPassword, customer.getPasswordHash());
    }
}
  • encode() generates a random salt each time it’s called, so the same rawPassword produces a different hash each time — this is correct behavior, not a bug.
  • matches() automatically extracts the salt from the stored hash, re-hashes the entered password, and compares them; you don’t need to manage the salt yourself.

4.4. Test behavior

import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import static org.assertj.core.api.Assertions.assertThat;

class BcryptPasswordEncoderTest {

    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);

    @Test
    void shouldGenerateDifferentHashesForSamePassword() {
        String hash1 = encoder.encode("S3cretPass!");
        String hash2 = encoder.encode("S3cretPass!");

        assertThat(hash1).isNotEqualTo(hash2);
        assertThat(encoder.matches("S3cretPass!", hash1)).isTrue();
        assertThat(encoder.matches("S3cretPass!", hash2)).isTrue();
    }

    @Test
    void shouldRejectWrongPassword() {
        String hash = encoder.encode("S3cretPass!");

        assertThat(encoder.matches("WrongPass!", hash)).isFalse();
    }
}

This test confirms two important things: the random salt makes hashes different, and matches() still recognizes the correct password even when hashes differ.

Note: bcrypt has a maximum input length of 72 bytes. If the password is longer, the excess is silently truncated. With typical passwords (under 72 characters) this isn’t an issue, but if you allow very long passphrases, consider argon2.

5. Using argon2 — a more modern choice

Argon2 won the Password Hashing Competition in 2015 and is recommended by OWASP as the first choice today because it resists GPU-cracking better than bcrypt (memory-hard).

5.1. Dependency

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk18on</artifactId>
    <version>1.78.1</version>
</dependency>

Spring Security uses Bouncy Castle as the provider for Argon2PasswordEncoder, so you need to add this dependency.

5.2. Bean declaration

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // saltLength=16, hashLength=32, parallelism=1, memory=1<<16 (64MB), iterations=3
        return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
    }
}

The factory method defaultsForSpringSecurity_v5_8() returns a parameter configuration that the Spring Security team has verified is safe for most applications, so you don’t need to calculate memory/iterations yourself.

5.3. Comparing output

$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$5t1h3u7K3iC2rT0...

The structure includes the variant (argon2id), version, memory parameters (m), iterations (t), parallelism (p), then the salt and hash — more transparent than bcrypt because the cost is explicitly shown.

5.4. Test comparing both encoders

import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;

import static org.assertj.core.api.Assertions.assertThat;

class Argon2PasswordEncoderTest {

    private final Argon2PasswordEncoder encoder =
            Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();

    @Test
    void shouldVerifyCorrectPassword() {
        String hash = encoder.encode("MyP@ssw0rd123");

        assertThat(encoder.matches("MyP@ssw0rd123", hash)).isTrue();
        assertThat(encoder.matches("wrong", hash)).isFalse();
    }
}

6. Supporting multiple encoders simultaneously with DelegatingPasswordEncoder

In practice, your system might need to change algorithms over time (for example, migrating from bcrypt to argon2) without breaking old hashes. Spring Security provides a solution for this.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

createDelegatingPasswordEncoder() creates an encoder that knows how to read prefixes like {bcrypt}, {argon2}, {noop}… in a hash string to choose the right decoding algorithm. By default, it uses bcrypt to encode new passwords, but still correctly matches() hashes created with other algorithms.

{bcrypt}$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
{argon2}$argon2id$v=19$m=65536,t=3,p=1$...

With this prefix, you can migrate gradually: old users can still log in, and new users or those who change their password will have hashes created with the latest algorithm.

7. Quick comparison of bcrypt and argon2

Criterion bcrypt argon2
Year introduced 1999 2015 (PHC winner)
Resists GPU/ASIC Good Better (memory-hard)
Input length limit 72 bytes No practical limit
Popularity Very high, supported everywhere Increasingly popular
OWASP recommendation Second choice First choice

If your project is new and not constrained by legacy systems, choose argon2id (the argon2id variant is the default in Spring Security, balancing resistance to side-channel and GPU attacks). If your system has been using bcrypt stably for years, you don’t necessarily need to migrate right away — increasing the bcrypt cost factor to 12–14 is still secure for most applications.

8. Common mistakes

  • Manually implementing salt + SHA-256: as mentioned in section 2, it still lacks work factor and is weak against modern brute-force.
  • Using too low a cost factor: BCryptPasswordEncoder() with no parameter defaults to cost 10, but if you lower it to 4–5 to “speed up” testing, it’s easy to forget to change it back when deploying to production.
  • Comparing hashes with String.equals(): never manually compare hash strings — always use the matches() method of PasswordEncoder, because these libraries compare in constant-time fashion to avoid timing attacks.
  • Storing salt in a separate DB column: unnecessary, both bcrypt and argon2 embed the salt in the hash output.
  • No rate limiting on login attempts: no matter how strong the hash, without rate limiting or account lockout, online brute-force is still possible.

Note: Never use the {noop} encoder (plaintext) in production. It should only appear in tests or local demos.

9. Conclusion

Hashing passwords correctly means using an algorithm designed for this purpose — bcrypt or argon2 — instead of fast hash functions like SHA-256. Spring Security makes this simple: just declare a PasswordEncoder bean, call encode() when saving and matches() when authenticating, without managing salt yourself. If you need flexible migration between algorithms, DelegatingPasswordEncoder is a safe and backward-compatible choice. Whether you choose bcrypt or argon2, remember that strong hashing is just one layer of defense — combine it with rate limiting and MFA to create a truly secure authentication system.

web-securityspring-securitybcryptargon2authentication