HomeSpring BootBCryptPasswordEncoder
Spring Boot

BCryptPasswordEncoder

BCryptPasswordEncoder is Spring Security's bcrypt implementation of PasswordEncoder. Bcrypt is deliberately slow, generates a fresh random salt for every hash and stores that salt inside the hash itself, and has a cost setting you can raise as hardware gets faster. Using it correctly is mostly about the details around the call: one shared bean, a strength you have measured, a password length limit, transparent upgrades of old hashes at login, and tests that run quickly without bypassing the hashing. This entry covers all of those, plus when to consider Argon2.

What a Bcrypt Hash Contains

A bcrypt hash is a single 60-character string that carries everything needed to check a password later: the algorithm version, the cost, the random salt and the digest. That is why you store only one column and never a separate salt, and why encoding the same password twice gives two different results. matches() reads the salt and cost out of the stored hash, hashes the candidate the same way and compares. Cost is logarithmic. Each increase of one in strength doubles the work, so strength 12 is four times as slow as strength 10. Because the cost is recorded in each hash, you can raise it over time and old hashes still verify; they simply report themselves as candidates for upgrade (covered below). There is one limit to design around: bcrypt only uses the first 72 bytes of the input. Bytes, not characters, so a password of non-ASCII characters hits the limit sooner. Depending on the Spring Security version a longer password is truncated silently or rejected, and truncation means two different long passwords can be treated as identical. Enforce a maximum length yourself and show the user a clear message.
Java
// $2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
// \__/\/ \____________________/\_____________________________/
//  |   |          |                          |
//  |   |          |                          +-- 31 chars: the hash digest
//  |   |          +-- 22 chars: the random salt, generated per password
//  |   +-- cost (strength): 2^12 rounds
//  +-- algorithm version

// The same input hashes differently every time, and both hashes verify:
PasswordEncoder encoder = new BCryptPasswordEncoder(12);
String first  = encoder.encode("myPassword");
String second = encoder.encode("myPassword");

first.equals(second);                        // false, because the salts differ
encoder.matches("myPassword", first);        // true
encoder.matches("myPassword", second);       // true

// Store it in a column that is at least 60 characters wide. If you move to DelegatingPasswordEncoder
// later (see the migration section) a prefix like {bcrypt} is added, so VARCHAR(100) is a safer choice.

Declaring the Bean

Declare exactly one PasswordEncoder bean and inject it everywhere. The reason is consistency rather than speed: BCryptPasswordEncoder is stateless and cheap to create, so there is no warm-up to lose, but one shared bean guarantees that registration, password change and login all use the same settings. Declaring two PasswordEncoder beans is a common mistake, because injection by type then fails with a NoUniqueBeanDefinitionException. Read the strength from configuration instead of hard-coding it. That lets production use a strong value, tests use the minimum, and you can raise the cost later without a code change. The default is 10. The default SecureRandom is already appropriate, so there is no need to pass your own, and the optional BCryptVersion argument only matters if you must interoperate with another system that insists on a particular prefix such as $2b.
Java
@Configuration
public class PasswordConfig {

    @Bean
    PasswordEncoder passwordEncoder(
            @Value("${security.password.bcrypt-strength:12}") int strength) {
        return new BCryptPasswordEncoder(strength);
    }
}

// application.yml
//   security:
//     password:
//       bcrypt-strength: 12
//
// application-test.yml
//   security:
//     password:
//       bcrypt-strength: 4      # the lowest value bcrypt accepts: fast tests, same code path

Choosing the Strength

There is no universally correct number, because the right value depends on your hardware and how much login latency you can accept. The usual guidance is to pick the highest strength where a single verification still feels instant to a user, in the range of a couple of hundred milliseconds up to about a second, and never below 10. The Spring Security documentation suggests tuning for roughly one second on your own system; many teams settle lower because every login also costs CPU on the server. That last point matters. Each login attempt consumes the same CPU as a legitimate one, so a high strength combined with no rate limiting gives attackers a cheap way to exhaust your servers. Measure under realistic concurrency and put rate limiting or lockout on the login endpoint. Benchmark on production-class hardware, not your laptop, and warm the JVM up first: the first call is much slower than later ones, which is why a single timing misleads. Revisit the number every year or two.
Java
public final class BcryptBenchmark {

    public static void main(String[] args) {
        String sample = "SamplePassword123!";

        for (int strength = 10; strength <= 14; strength++) {
            BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(strength);
            encoder.encode(sample);                       // warm-up run, not measured

            int runs = 5;
            long start = System.nanoTime();
            for (int i = 0; i < runs; i++) {
                encoder.encode(sample);
            }
            long millis = (System.nanoTime() - start) / runs / 1_000_000;
            System.out.printf("strength %d: about %d ms per hash%n", strength, millis);
        }
    }
}

// Each +1 doubles the time. Typical output on a modern server core is somewhere around
// 60-80 ms at strength 10, 250-300 ms at 12, and over a second at 14. Your numbers will differ.

Registration, Password Change and Reset

Encode at the moment a password is created or changed, store only the hash, and never alter the password before encoding. Trimming whitespace or changing case on a password changes what the user actually typed. Normalise the email address, not the password, and use Locale.ROOT when lower-casing so a Turkish default locale does not turn "I" into a dotless character. Check for a duplicate email by relying on the database's unique constraint rather than a prior existsBy query, since two simultaneous registrations can both pass the check. Catch the constraint violation and translate it. Keep in mind that telling a visitor that an email is already registered lets anyone test which addresses have accounts; for sensitive applications, respond the same way either way and send an email. A password policy belongs next to the encoder. Set a reasonable minimum length, allow long passphrases up to the 72-byte bcrypt limit, and avoid arbitrary composition rules. Checking against a list of known breached passwords gives more protection than requiring a symbol. For reset flows, generate the token with SecureRandom and store only a hash of it. A high-entropy random token does not need a slow hash, so SHA-256 is fine here, and a leaked database then cannot be used to take over accounts. Make tokens single-use and short-lived, and after any password change or reset, invalidate the user's existing sessions and refresh tokens.
Java
@Component
public class PasswordPolicy {

    private static final int MIN_LENGTH = 8;
    private static final int MAX_BYTES  = 72;   // bcrypt ignores anything beyond this

    public void check(String password) {
        if (password == null || password.length() < MIN_LENGTH) {
            throw new WeakPasswordException("Password must be at least " + MIN_LENGTH + " characters");
        }
        if (password.getBytes(StandardCharsets.UTF_8).length > MAX_BYTES) {
            throw new WeakPasswordException("Password must be at most " + MAX_BYTES + " bytes");
        }
        // Also consider rejecting known-breached passwords (for example via a local list).
    }
}

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepo;
    private final PasswordResetTokenRepository tokenRepo;
    private final PasswordEncoder passwordEncoder;
    private final PasswordPolicy passwordPolicy;

    @Transactional
    public UserResponse register(RegisterRequest request) {
        passwordPolicy.check(request.password());

        User user = new User();
        user.setEmail(request.email().trim().toLowerCase(Locale.ROOT));   // normalise the email only
        user.setPassword(passwordEncoder.encode(request.password()));      // never trim the password
        user.setRoles(Set.of("USER"));

        try {
            return UserResponse.from(userRepo.saveAndFlush(user));
        } catch (DataIntegrityViolationException ex) {                    // unique constraint on email
            throw new DuplicateEmailException(user.getEmail());
        }
    }

    @Transactional
    public void changePassword(Long userId, ChangePasswordRequest request) {
        User user = userRepo.findById(userId).orElseThrow();

        if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) {
            throw new InvalidPasswordException("Current password is incorrect");
        }
        passwordPolicy.check(request.newPassword());

        user.setPassword(passwordEncoder.encode(request.newPassword()));   // managed entity, saved on commit
        // Also revoke this user's other sessions and refresh tokens here.
    }

    @Transactional
    public void resetPassword(String rawToken, String newPassword) {
        passwordPolicy.check(newPassword);

        // Only a SHA-256 of the token is stored, so a database leak does not expose usable tokens.
        PasswordResetToken token = tokenRepo.findByTokenHash(TokenHasher.sha256Hex(rawToken))
            .filter(t -> t.getExpiresAt().isAfter(Instant.now()))
            .orElseThrow(() -> new InvalidTokenException("Reset token invalid or expired"));

        token.getUser().setPassword(passwordEncoder.encode(newPassword));
        tokenRepo.delete(token);   // single use
    }
}

Upgrading Old Hashes at Login

You cannot re-hash a password you do not have, but you do have the plain text for a moment at every successful login. Spring Security uses that moment. After DaoAuthenticationProvider verifies a password it asks the encoder whether the stored hash should be upgraded, and if so it re-encodes the password with the current settings and hands the result to a UserDetailsPasswordService bean for you to save. Users never notice, and the whole population drifts to the stronger setting. Two limits apply: only users who actually log in are upgraded, and the check runs only after a successful match. For a plain BCryptPasswordEncoder, upgradeEncoding returns true when the stored hash has a lower cost than the configured one. That covers raising strength from 10 to 12. It does not cover a move from another algorithm, because a bcrypt encoder cannot verify an MD5 or SHA hash at all. For that case use DelegatingPasswordEncoder. New passwords are encoded with bcrypt and stored with a {bcrypt} prefix, and the encoder picks the right algorithm from the prefix on later checks. Legacy rows have no prefix, so tell it which encoder to use for them with setDefaultPasswordEncoderForMatches; any hash that is not in the current format is then reported as needing an upgrade. The plain MD5 and SHA encoders are deprecated in Spring Security and are only there for this kind of migration. If you cannot wait for users to log in, you can also wrap the old hashes offline, storing bcrypt(oldHash) and recording that fact, but that is a larger change.
Java
@Configuration
public class PasswordConfig {

    // Used while migrating from legacy unsalted hashes. Remove the legacy part once every row is upgraded.
    @Bean
    @SuppressWarnings("deprecation")
    PasswordEncoder passwordEncoder(
            @Value("${security.password.bcrypt-strength:12}") int strength) {

        Map<String, PasswordEncoder> encoders =
            Map.of("bcrypt", new BCryptPasswordEncoder(strength));

        DelegatingPasswordEncoder delegating = new DelegatingPasswordEncoder("bcrypt", encoders);

        // Stored values WITHOUT an {id} prefix are verified with this encoder. Use whatever produced
        // your old hashes. If your existing rows are already plain bcrypt ($2a$...), use a
        // BCryptPasswordEncoder here instead and they will upgrade to the prefixed format.
        delegating.setDefaultPasswordEncoderForMatches(new MessageDigestPasswordEncoder("MD5"));

        return delegating;
    }
}

@Service
@RequiredArgsConstructor
@Slf4j
public class AppUserDetailsService implements UserDetailsService, UserDetailsPasswordService {

    private final UserRepository userRepo;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String email) {
        return userRepo.findByEmail(email)
            .map(AuthenticatedUser::from)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
        // DaoAuthenticationProvider turns this into a generic "bad credentials" for the client.
    }

    // Called after a successful login when the encoder says the stored hash is outdated.
    @Override
    @Transactional
    public UserDetails updatePassword(UserDetails details, String newEncodedPassword) {
        User user = userRepo.findByEmail(details.getUsername()).orElseThrow();
        user.setPassword(newEncodedPassword);   // saved on commit
        log.info("Upgraded password hash for user id {}", user.getId());   // never log the hash itself
        return AuthenticatedUser.from(user);
    }
}

Testing with Bcrypt

Bcrypt's slowness makes a test suite that hashes at strength 12 on every run drag. The fix is to turn the cost down, not to replace the encoder. Setting strength to 4, the minimum bcrypt allows, keeps tests fast and still runs the real hashing code, so you test the behaviour you ship. NoOpPasswordEncoder, the usual shortcut, stores plain text, is deprecated, and means your tests never exercise hashing, salting or verification at all. Also be wary of tests that only call encoder.matches on a hash they just made. They check the library, not your application. Better assertions target your own behaviour: the stored value is a hash and not the raw password, a change of password replaces the hash, the old password stops working, a wrong current password is rejected, and a stale hash gets flagged for upgrade. Pre-computing an encoded password once per class is still a good idea for tests that create many users, and with strength 4 it costs almost nothing.
Java
// src/test/resources/application-test.yml
//   security:
//     password:
//       bcrypt-strength: 4

@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest {

    private static final String PASSWORD = "TestPassword123!";

    @Autowired private UserService userService;
    @Autowired private UserRepository userRepo;
    @Autowired private PasswordEncoder passwordEncoder;   // the real bean, at strength 4

    private User user;

    @BeforeEach
    void setUp() {
        userRepo.deleteAll();
        user = new User();
        user.setEmail("test@example.com");
        user.setPassword(passwordEncoder.encode(PASSWORD));
        user.setRoles(Set.of("USER"));
        user.setEnabled(true);
        user = userRepo.save(user);
    }

    @Test
    void register_storesAHashNotThePassword() {
        userService.register(new RegisterRequest("new@example.com", PASSWORD));

        String stored = userRepo.findByEmail("new@example.com").orElseThrow().getPassword();
        assertThat(stored).isNotEqualTo(PASSWORD).startsWith("$2");
        assertThat(passwordEncoder.matches(PASSWORD, stored)).isTrue();
    }

    @Test
    void changePassword_replacesTheHashAndInvalidatesTheOldPassword() {
        String oldHash = user.getPassword();

        userService.changePassword(user.getId(), new ChangePasswordRequest(PASSWORD, "NewPassword456!"));

        String newHash = userRepo.findById(user.getId()).orElseThrow().getPassword();
        assertThat(newHash).isNotEqualTo(oldHash);
        assertThat(passwordEncoder.matches("NewPassword456!", newHash)).isTrue();
        assertThat(passwordEncoder.matches(PASSWORD, newHash)).isFalse();
    }

    @Test
    void changePassword_rejectsAWrongCurrentPassword() {
        assertThatThrownBy(() -> userService.changePassword(
                user.getId(), new ChangePasswordRequest("WrongPassword", "NewPassword456!")))
            .isInstanceOf(InvalidPasswordException.class);
    }

    @Test
    void register_rejectsPasswordsOverTheBcryptLimit() {
        String tooLong = "a".repeat(73);
        assertThatThrownBy(() -> userService.register(new RegisterRequest("x@example.com", tooLong)))
            .isInstanceOf(WeakPasswordException.class);
    }
}

// Plain unit test for the upgrade mechanism your login flow relies on (no Spring context needed):
class BcryptUpgradeTest {

    @Test
    void lowerCostHashesVerifyButAreFlaggedForUpgrade() {
        BCryptPasswordEncoder current = new BCryptPasswordEncoder(6);
        String oldHash = new BCryptPasswordEncoder(4).encode("pw");

        assertThat(current.matches("pw", oldHash)).isTrue();
        assertThat(current.upgradeEncoding(oldHash)).isTrue();
        assertThat(current.upgradeEncoding(current.encode("pw"))).isFalse();
    }
}

Pitfalls and When to Choose Something Else

Never log passwords or hashes, including at debug level and in request logging, and keep password fields out of toString methods and exception messages. Do not write your own login code that returns early when the user does not exist. Looking up a missing user and skipping the hash makes the request noticeably faster, which tells an attacker which accounts exist. DaoAuthenticationProvider already compensates by hashing a dummy value; if you build a custom flow, do the same. Keep error messages identical for "no such user" and "wrong password". Bcrypt is a sound choice and remains acceptable, but it is no longer the newest recommendation. OWASP's current guidance lists Argon2id first, because it is memory-hard and so resists GPU and ASIC cracking better, with bcrypt as a fine option when Argon2 is unavailable or when you have an existing bcrypt user base. Spring Security supports Argon2 (it needs the BouncyCastle library on the classpath), and because DelegatingPasswordEncoder records the algorithm in each hash, moving later is the same upgrade-at-login process described above. Last, remember that password hashing only limits the damage of a leaked database. Pair it with rate limiting or lockout on login, multi-factor authentication for anything sensitive, and breached-password checks.
Java
// Alternative: Argon2id. Needs org.bouncycastle:bcpkix-jdk18on on the classpath.
@Bean
PasswordEncoder argon2PasswordEncoder() {
    return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}

// Best of both: a delegating encoder that writes Argon2 and still verifies existing bcrypt hashes.
@Bean
PasswordEncoder migratingEncoder() {
    Map<String, PasswordEncoder> encoders = Map.of(
        "argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(),
        "bcrypt", new BCryptPasswordEncoder(12));

    DelegatingPasswordEncoder delegating = new DelegatingPasswordEncoder("argon2", encoders);
    delegating.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder(12));   // unprefixed $2a$ rows
    return delegating;
}