Secure Password Hashing with BCrypt
Learn how to securely store passwords in Java and Spring Boot using BCrypt. Understand hashing, salting, PasswordEncoder, registration flow, login verification, database storage, and production best practices.
Introduction
Password security is one of the most important parts of application security.
A common beginner mistake is storing passwords directly in the database.
That is dangerous.
If the database is leaked, every user's password is exposed.
In real applications, passwords must never be stored as plain text.
Instead, passwords should be converted into secure hashes using algorithms like BCrypt.
In this article, we will learn:
- Why plain text passwords are dangerous
- Difference between hashing and encryption
- What BCrypt is
- Why BCrypt is widely used
- How Spring Security
PasswordEncoderworks - How to hash passwords during registration
- How to verify passwords during login
- How to store hashed passwords in a database
- Production best practices
1. Plain Text Password Problem
Bad design:
Username: venu
Password: password123
If this value is stored directly in the database, anyone with database access can see the password.
flowchart LR
User["User enters password"]
--> App["Java Application"]
--> DB["Database stores plain password"]
DB --> Risk["Password exposed if DB leaks"]
Why This Is Dangerous
If passwords are stored as plain text:
- Database admins can see passwords
- Attackers can steal all passwords
- Users may reuse same password across websites
- Compliance violations can happen
- Company reputation is damaged
2. What is Password Hashing?
Hashing converts a password into a one-way fixed output.
Example:
password123
becomes:
$2a$10$WzLkB5aXAcq...
Hashing is one-way.
That means the original password cannot be directly recovered from the hash.
flowchart LR
Password["password123"]
--> HashFunction["BCrypt Hash Function"]
--> Hash["$2a$10$WzLkB5aX..."]
3. Hashing vs Encryption
Many developers confuse hashing and encryption.
| Concept | Hashing | Encryption |
|---|---|---|
| Purpose | Verify data | Protect and recover data |
| Reversible? | No | Yes |
| Uses Key? | Usually no | Yes |
| Password Storage | Yes | No |
| Example | BCrypt | AES |
Simple Explanation
Hashing:
Password → Hash
Encryption:
Plain Text → Encrypted Text → Decrypted Text
For passwords, use hashing.
Do not encrypt passwords.
4. Why Not Use MD5 or SHA-256?
Algorithms like MD5 and SHA-256 are fast.
Fast is good for normal hashing, but bad for password hashing.
Attackers can try billions of password guesses quickly.
flowchart TB
WeakHash["Fast Hash: MD5 / SHA-256"]
--> FastAttack["Attacker can brute force quickly"]
BCrypt["Slow Hash: BCrypt"]
--> SlowAttack["Brute force becomes expensive"]
For password storage, use slow adaptive algorithms:
- BCrypt
- Argon2
- PBKDF2
- SCrypt
In Spring Boot applications, BCrypt is commonly used.
5. What is BCrypt?
BCrypt is a password hashing algorithm designed for secure password storage.
BCrypt provides:
- Salt generation
- Configurable strength
- Slow hashing
- Protection against brute-force attacks
flowchart LR
Password["Password"]
--> Salt["Random Salt"]
--> BCrypt["BCrypt Algorithm"]
--> Hash["Stored BCrypt Hash"]
6. What is Salt?
A salt is a random value added to the password before hashing.
Without salt:
password123 → same hash every time
With salt:
password123 + salt1 → hash1
password123 + salt2 → hash2
Same password produces different hashes.
flowchart TB
Password["password123"]
Password --> Salt1["Salt A"] --> Hash1["Hash A"]
Password --> Salt2["Salt B"] --> Hash2["Hash B"]
This prevents attackers from using precomputed hash tables.
7. BCrypt Hash Structure
A BCrypt hash usually looks like this:
$2a$10$KbQiHKq7IGRz3yLMhkG4nepWe7T1LxGr/ktS9OEPvXEG6Q8QXGd9e
It contains:
$2a$ → BCrypt version
10 → Strength / cost factor
Salt → Random salt
Hash → Final password hash
flowchart LR
Hash["BCrypt Hash"]
--> Version["Version"]
--> Cost["Cost Factor"]
--> Salt["Salt"]
--> Value["Hash Value"]
8. Registration Flow
During registration:
- User sends username and password
- Application validates input
- Password is hashed using BCrypt
- Hashed password is stored in database
- Plain password is never stored
sequenceDiagram
participant User
participant API
participant PasswordEncoder
participant Database
User->>API: Register username/password
API->>API: Validate request
API->>PasswordEncoder: Encode raw password
PasswordEncoder-->>API: BCrypt hash
API->>Database: Save user with hashed password
Database-->>API: User saved
API-->>User: Registration success
9. Login Flow
During login:
- User enters username and password
- Application finds user by username
- Application compares raw password with stored hash
- If matched, login is successful
- If not matched, login fails
sequenceDiagram
participant User
participant API
participant Database
participant PasswordEncoder
User->>API: Login username/password
API->>Database: Find user by username
Database-->>API: User with hashed password
API->>PasswordEncoder: matches(rawPassword, storedHash)
PasswordEncoder-->>API: true or false
API-->>User: Login success/failure
Important:
The application does not decrypt the password.
It compares the raw password against the stored hash.
10. Spring Boot Project Setup
Required Dependencies
<dependencies>
<!-- Spring Web for REST APIs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security for PasswordEncoder and security support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Data JPA for database access -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database for local testing -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation for request validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
11. Project Structure
secure-password-demo
└── src
└── main
└── java
└── com.codewithvenu.security
├── controller
│ └── AuthController.java
├── dto
│ ├── RegisterRequest.java
│ ├── LoginRequest.java
│ └── AuthResponse.java
├── entity
│ └── AppUser.java
├── repository
│ └── AppUserRepository.java
├── service
│ └── AuthService.java
├── config
│ ├── PasswordConfig.java
│ └── SecurityConfig.java
└── SecurePasswordDemoApplication.java
12. application.yml
spring:
datasource:
url: jdbc:h2:mem:securitydb
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: update
show-sql: true
Explanation
jdbc:h2:mem:securitydb
Creates an in-memory database.
ddl-auto: update
Automatically creates or updates database tables.
show-sql: true
Prints generated SQL queries in the console.
13. Create User Entity
package com.codewithvenu.security.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "app_users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String role;
protected AppUser() {
}
public AppUser(String username, String password, String role) {
this.username = username;
this.password = password;
this.role = role;
}
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public String getRole() {
return role;
}
}
Code Explanation
@Entity
Marks this class as a JPA entity.
@Table(name = "app_users")
Maps the entity to the app_users table.
@Id
Marks the primary key.
@GeneratedValue
Automatically generates ID values.
@Column(nullable = false, unique = true)
Username is required and must be unique.
password
Stores the BCrypt hashed password, not the raw password.
14. Create Repository
package com.codewithvenu.security.repository;
import com.codewithvenu.security.entity.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsername(String username);
}
Explanation
JpaRepository<AppUser, Long>
Provides CRUD operations for AppUser.
findByUsername
Used during login to find the user record.
15. Create DTOs
RegisterRequest
package com.codewithvenu.security.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank(message = "Username is required")
String username,
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
String password
) {
}
LoginRequest
package com.codewithvenu.security.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank(message = "Username is required")
String username,
@NotBlank(message = "Password is required")
String password
) {
}
AuthResponse
package com.codewithvenu.security.dto;
public record AuthResponse(
String message
) {
}
16. Create PasswordEncoder Bean
package com.codewithvenu.security.config;
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 PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
Code Explanation
PasswordEncoder
Spring Security interface for password hashing and verification.
BCryptPasswordEncoder(12)
Uses BCrypt strength 12.
Higher strength means stronger but slower hashing.
Common values:
| Strength | Usage |
|---|---|
| 10 | Default learning/demo |
| 12 | Good production starting point |
| 14+ | Stronger but slower |
17. Create Security Configuration
For this article, we allow register and login APIs publicly.
package com.codewithvenu.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
);
http.headers(headers -> headers.frameOptions(frame -> frame.disable()));
return http.build();
}
}
Explanation
csrf.disable()
Disabled for this REST API demo.
permitAll()
Allows registration and login without authentication.
h2-console
Allowed for local learning.
frameOptions.disable()
Required for H2 console iframe.
Do not expose H2 console in production.
18. Create AuthService
package com.codewithvenu.security.service;
import com.codewithvenu.security.dto.AuthResponse;
import com.codewithvenu.security.dto.LoginRequest;
import com.codewithvenu.security.dto.RegisterRequest;
import com.codewithvenu.security.entity.AppUser;
import com.codewithvenu.security.repository.AppUserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final AppUserRepository repository;
private final PasswordEncoder passwordEncoder;
public AuthService(AppUserRepository repository,
PasswordEncoder passwordEncoder) {
this.repository = repository;
this.passwordEncoder = passwordEncoder;
}
public AuthResponse register(RegisterRequest request) {
boolean userExists = repository
.findByUsername(request.username())
.isPresent();
if (userExists) {
return new AuthResponse("Username already exists");
}
String hashedPassword = passwordEncoder.encode(request.password());
AppUser user = new AppUser(
request.username(),
hashedPassword,
"USER"
);
repository.save(user);
return new AuthResponse("User registered successfully");
}
public AuthResponse login(LoginRequest request) {
AppUser user = repository
.findByUsername(request.username())
.orElseThrow(() -> new RuntimeException("Invalid username or password"));
boolean passwordMatches = passwordEncoder.matches(
request.password(),
user.getPassword()
);
if (!passwordMatches) {
throw new RuntimeException("Invalid username or password");
}
return new AuthResponse("Login successful");
}
}
Code Explanation
repository.findByUsername(request.username())
Checks whether the username already exists.
passwordEncoder.encode(request.password())
Hashes the raw password using BCrypt.
repository.save(user)
Saves the user with hashed password.
passwordEncoder.matches(rawPassword, storedHash)
Compares raw login password with stored BCrypt hash.
19. Create AuthController
package com.codewithvenu.security.controller;
import com.codewithvenu.security.dto.AuthResponse;
import com.codewithvenu.security.dto.LoginRequest;
import com.codewithvenu.security.dto.RegisterRequest;
import com.codewithvenu.security.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService service;
public AuthController(AuthService service) {
this.service = service;
}
@PostMapping("/register")
public AuthResponse register(@Valid @RequestBody RegisterRequest request) {
return service.register(request);
}
@PostMapping("/login")
public AuthResponse login(@Valid @RequestBody LoginRequest request) {
return service.login(request);
}
}
Explanation
@RestController
Creates REST APIs.
@RequestMapping("/api/auth")
Base path for authentication APIs.
@PostMapping("/register")
Registration API.
@PostMapping("/login")
Login API.
@Valid
Validates request body.
20. Test Registration API
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "venu",
"password": "password123"
}'
Expected response:
{
"message": "User registered successfully"
}
21. Check Stored Password in H2
Open:
http://localhost:8080/h2-console
Run:
select * from app_users;
You should see password like:
$2a$12$hY43nLmkVqA7bk...
Not:
password123
flowchart LR
Raw["password123"]
--> BCrypt["BCrypt"]
--> Stored["$2a$12$..."]
22. Test Login API
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "venu",
"password": "password123"
}'
Expected response:
{
"message": "Login successful"
}
Wrong password:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "venu",
"password": "wrongpassword"
}'
Expected result:
Invalid username or password
23. Why BCrypt Produces Different Hashes Every Time
Run this:
PasswordEncoder encoder = new BCryptPasswordEncoder();
System.out.println(encoder.encode("password123"));
System.out.println(encoder.encode("password123"));
System.out.println(encoder.encode("password123"));
Output will be different each time:
$2a$10$abc...
$2a$10$xyz...
$2a$10$pqr...
Because BCrypt generates a new salt for each hash.
But all hashes still match the same raw password.
encoder.matches("password123", storedHash);
24. Password Verification Flow
flowchart TB
Login["User Login"]
--> RawPassword["Raw Password"]
RawPassword --> StoredHash["Stored BCrypt Hash"]
StoredHash --> BCryptCheck["BCrypt matches()"]
BCryptCheck --> Result{"Match?"}
Result -- Yes --> Success["Login Success"]
Result -- No --> Failure["Login Failed"]
25. Production Best Practices
Do
- Use BCrypt, Argon2, PBKDF2, or SCrypt
- Use
PasswordEncoder - Use minimum password length
- Return generic login error
- Use HTTPS
- Rate limit login attempts
- Monitor failed login attempts
- Store only password hashes
- Rotate compromised passwords
Do Not
- Do not store plain passwords
- Do not encrypt passwords
- Do not log passwords
- Do not expose password hash in API responses
- Do not commit secrets to GitHub
- Do not use
{noop}in production
26. Common Mistakes
| Mistake | Problem |
|---|---|
| Storing plain password | Complete password exposure |
| Using MD5/SHA-1 | Too fast and weak |
| Logging password | Sensitive data leak |
| Returning "username not found" | User enumeration risk |
| Using same password hash manually | Missing salt benefits |
| Disabling HTTPS | Credentials can be intercepted |
27. Better Error Handling
Bad:
Username not found
Bad:
Password incorrect
Good:
Invalid username or password
Why?
Because attackers should not know whether username exists.
28. Interview Questions
Q1. Why should passwords be hashed?
Because passwords should not be readable even if the database is compromised.
Q2. Is hashing reversible?
No. Hashing is one-way.
Q3. Should passwords be encrypted?
No. Passwords should be hashed, not encrypted.
Q4. What is salt?
A salt is a random value added before hashing to produce unique hashes.
Q5. Why does BCrypt produce different hashes for the same password?
Because BCrypt generates a new salt each time.
Q6. What does matches() do?
It compares a raw password with a stored hash.
Q7. What is BCrypt strength?
Strength controls hashing cost. Higher strength means slower and harder to brute force.
Q8. Why avoid MD5 or SHA-256 for passwords?
They are too fast and easier to brute force.
29. Key Takeaways
- Never store plain text passwords.
- Passwords should be hashed, not encrypted.
- BCrypt is a strong password hashing algorithm.
- BCrypt automatically uses salt.
- Spring Security provides
PasswordEncoder. - Use
encode()during registration. - Use
matches()during login. - Store only hashed passwords in the database.
- Never log passwords or password hashes.
- Use HTTPS and rate limiting in production.
Next Article
➡️ Role-Based Access Control with Spring Security