Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 055 / 100

Password hashing with BCrypt in Spring, explained simply

Never store passwords — store slow, salted hashes.

Expona Admin8 min read

Storing passwords in plain text is one of the most dangerous mistakes a backend engineer can make. When a database breach happens—and it will—attackers get immediate access to every user account. Password hashing with BCrypt in Spring, explained simply, means using a deliberately slow algorithm that's nearly impossible to reverse, even with modern hardware.

Why plain text passwords fail (and why speed matters)

When you store a user's password as plain text, you've created a ticking time bomb. An attacker who gains database access can immediately log in as anyone. Even a basic laptop can check billions of passwords per second against an MD5 hash, which is why legacy hashing algorithms are no longer acceptable.

BCrypt is different. It's intentionally slow. A properly configured BCrypt hash takes roughly 100–200 milliseconds to compute on modern hardware. This means an attacker checking a million passwords would need weeks, not minutes. That delay is your security. Every system we've worked on—whether handling ride-hailing users or e-commerce customers—relies on this slowness as the primary defense.

The speed gap is critical: validating a user's login takes a fraction of a second. An attacker trying to crack your hash offline needs the same time per guess. Multiply that by millions of attempts, and the attack becomes economically irrational.

Use PasswordEncoderFactories.createDelegatingPasswordEncoder

Spring Security provides a factory method that handles password encoding elegantly. Rather than hardcoding a single algorithm, PasswordEncoderFactories.createDelegatingPasswordEncoder() returns a delegating encoder that can handle multiple algorithm formats and automatically upgrade users to stronger algorithms over time.

java

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

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

This bean returns a delegating encoder that defaults to BCrypt. The encoder stores hashes with a prefix like {bcrypt} or {argon2}, making it algorithm-agnostic. When you validate a login, the encoder reads the prefix and uses the correct algorithm to verify.

Why is this useful? Imagine you start with BCrypt in 2024 but want to migrate to Argon2 in 2026. Users don't re-enter their passwords on migration day. Instead, on their next login, you transparently re-hash their password with the new algorithm and update the database record. The prefix system makes this transparent—no application restarts, no downtime.

java

@Service
public class UserService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    
    @Autowired
    private UserRepository userRepository;
    
    public void registerUser(String email, String plainPassword) {
        String encodedPassword = passwordEncoder.encode(plainPassword);
        User user = new User();
        user.setEmail(email);
        user.setPassword(encodedPassword);  // Stored hash, not plain text
        userRepository.save(user);
    }
    
    public boolean validateLogin(String email, String plainPassword) {
        User user = userRepository.findByEmail(email);
        if (user == null) {
            return false;
        }
        return passwordEncoder.matches(plainPassword, user.getPassword());
    }
}

Notice that passwordEncoder.encode() generates a new salt and hash every time. The same password produces different hashes on each call. When validating, passwordEncoder.matches() extracts the salt from the stored hash and re-computes it, comparing the result. This is safe and standard.

BCrypt cost: tuning 10–12 for most applications

BCrypt uses a cost factor (also called work factor or rounds) that controls how slow the algorithm runs. The cost is exponential: cost 12 takes roughly four times longer than cost 10. Higher cost = more security, but at the expense of CPU and latency on login.

For most applications, cost 10–12 is the sweet spot. Here's why:

  • Cost 10: Hashing takes ~100 ms on modern hardware. Good for high-traffic systems where login latency matters (ride-hailing, e-commerce).
  • Cost 12: Hashing takes ~250–400 ms on modern hardware. Better security. Acceptable for most systems, including SaaS.
  • Cost 14+: Takes seconds. Only use if users rarely log in or if you handle hashing asynchronously.

To set the cost explicitly, create a BCryptPasswordEncoder bean instead of using the factory:

java

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 of 12
    }
}

When you upgrade the cost factor in production, only new registrations and password resets use the new cost. Existing hashes remain at their original cost until users log in and trigger re-encoding. This gradual migration keeps the system stable.

We've deployed systems across trading platforms and travel apps with cost 11 as the default, tuning down to 10 during peak traffic if needed. The key is monitoring: track login latency and CPU usage. If login requests start timing out, lower the cost slightly. If you have headroom, increase it every few years as hardware improves.

Argon2 for new systems (if available)

BCrypt has been battle-tested for decades and remains secure. But if you're building a new system from scratch and can afford slightly higher latency, Argon2 is worth considering. It won the Password Hashing Competition in 2015 and is designed for modern threats like GPU and ASIC cracking.

Spring Security includes Argon2PasswordEncoder. You can use it the same way:

java

import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new Argon2PasswordEncoder(
            16,      // salt length (bytes)
            32,      // hash length (bytes)
            1,       // parallelism (threads)
            60000,   // memory in KB (60 MB)
            3        // iterations
        );
    }
}

Argon2's advantage is that it uses memory and parallelism to defend against brute-force attacks. An attacker with a GPU can't simply parallelize the computation; they'd need to replicate the memory footprint across many machines, which is expensive.

The trade-off: Argon2 is slower and more CPU-intensive than BCrypt. We've seen login latency spike to 500+ ms with Argon2 at default settings, which can frustrate users. For systems where security is paramount (banking, healthcare), this is acceptable. For mobile or high-frequency logins, BCrypt remains practical.

If you use the delegating encoder, you can mix BCrypt and Argon2 hashes in the same database. New users get Argon2; existing BCrypt hashes work until they change their password.

Rate-limiting login attempts

Even with a slow hash, an attacker can still try to guess passwords if they have an internet connection. They submit millions of login attempts to your API. Rate-limiting is your second line of defense.

Implement rate-limiting per IP address and per email address. After N failed attempts in M seconds, reject further attempts:

java

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Service
public class LoginAttemptService {
    @Autowired
    private RedisTemplate redisTemplate;
    
    private static final int MAX_ATTEMPTS = 5;
    private static final long LOCK_DURATION_MINUTES = 15;
    
    public void recordFailedLogin(String email) {
        String key = "login_attempt:" + email;
        redisTemplate.opsForValue().increment(key);
        redisTemplate.expire(key, LOCK_DURATION_MINUTES, TimeUnit.MINUTES);
    }
    
    public boolean isBlocked(String email) {
        String key = "login_attempt:" + email;
        Object attempts = redisTemplate.opsForValue().get(key);
        return attempts != null && (Long) attempts >= MAX_ATTEMPTS;
    }
    
    public void resetAttempts(String email) {
        String key = "login_attempt:" + email;
        redisTemplate.delete(key);
    }
}

Then integrate it into your login endpoint:

java

@RestController
@RequestMapping("/auth")
public class AuthController {
    @Autowired
    private UserService userService;
    
    @Autowired
    private LoginAttemptService loginAttemptService;
    
    @PostMapping("/login")
    public ResponseEntity login(@RequestBody LoginRequest request) {
        if (loginAttemptService.isBlocked(request.getEmail())) {
            return ResponseEntity.status(429)
                .body("Too many failed attempts. Try again later.");
        }
        
        if (!userService.validateLogin(request.getEmail(), request.getPassword())) {
            loginAttemptService.recordFailedLogin(request.getEmail());
            return ResponseEntity.status(401).body("Invalid credentials");
        }
        
        loginAttemptService.resetAttempts(request.getEmail());
        return ResponseEntity.ok("Login successful");
    }
}

Redis is ideal for this because it's fast and naturally supports expiration. If you don't have Redis, a database with a TTL index works too, but it's slower. We've implemented this in CRM systems and trading platforms where brute-force protection is critical. It stops casual attacks and makes targeted cracking economically infeasible.

Common mistakes

Storing passwords in logs: Even if you hash passwords in the database, never log them. Disable debug logging of request bodies in production, or at minimum sanitize passwords before logging.

Using the same cost for all users: Don't hardcode cost 10 forever. Every few years, hardware improves and the same computation gets faster. Review your cost factor annually. If login latency is acceptable, increase cost to maintain security as hardware evolves.

Forgetting to salt: BCrypt and Argon2 salt automatically. MD5 or SHA-256 without salt are completely insecure. If you see an engineer proposing SHA-256 for passwords, that's a red flag.

Validating passwords client-side: Never send a plain password from the browser to the server without HTTPS, and never validate password complexity only on the client. An attacker can bypass JavaScript validation. Validate complexity on the server, always.

Comparing hashes with `==`: Use the Spring encoder's matches() method, never string comparison. String comparison can leak timing information to attackers (they can guess if the first character of the hash is correct by measuring response time).

Key takeaways

  • Never store plain text passwords. Always hash them with a slow, salted algorithm like BCrypt or Argon2.
  • Use PasswordEncoderFactories.createDelegatingPasswordEncoder() for flexibility and future-proof algorithm migration.
  • Tune BCrypt cost to 10–12 for most applications. Monitor login latency. Increase cost every 2–3 years as hardware improves.
  • Consider Argon2 for new systems if you can tolerate higher latency and memory overhead. Use the delegating encoder to mix algorithms during migration.
  • Rate-limit login attempts per email and IP address. After 5 failed attempts in 15 minutes, block further attempts. Store attempt counts in Redis for speed.
  • Never log passwords, compare hashes with ==, or validate complexity only on the client. These are critical security mistakes that we see regularly in code reviews.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles