Backend
7/6/2026
3 min read

Mastering Spring Security 7 with Spring Boot 4 and Java 21 – Part 4: Custom UserDetailsService and Database Authentication with Spring Data JPA

Mastering Spring Security 7 with Spring Boot 4 and Java 21 – Part 4: Custom UserDetailsService and Database Authentication with Spring Data JPA

In the previous article, we learned how Spring Security securely stores passwords using BCryptPasswordEncoder.

While in-memory users are useful for learning and testing, real-world applications store users in a database. When a user attempts to log in, Spring Security fetches user details from the database, validates the password, and grants access based on roles and permissions.

In this article, we will implement database authentication using:

  • Spring Security 7

  • Spring Boot 4

  • Java 21

  • Spring Data JPA

  • PostgreSQL (or MySQL)

  • BCryptPasswordEncoder

This approach is used in production systems such as e-commerce applications, HRMS platforms, banking systems, SaaS products, and enterprise applications.

Why In-Memory Authentication is Not Enough

Previously we created users like this:

UserDetailsuser=User.builder()
.username("john")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build();

Problems:

  • Users disappear after application restart.

  • New users cannot register.

  • Password updates are impossible.

  • Role management becomes difficult.

  • Not suitable for production.

Instead, users should be stored in a database.

Authentication Flow with Database

Client Login Request
        ↓
Spring Security
        ↓
UserDetailsService
        ↓
UserRepository
        ↓
DatabaseUser FoundPassword Validation
        ↓
Authentication Success

This is the standard authentication flow used by most enterprise applications.

Creating User Entity

First, create a database entity.

@Entity
@Table(name="users")
publicclassUser {

    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
privateLongid;

    @Column(nullable=false,
unique=true)
privateStringusername;

    @Column(nullable=false)
privateStringpassword;

    @Column(nullable=false)
privateStringrole;

// Getters and Setters
}

Understanding the Entity

id

Unique identifier for each user.

@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
privateLongid;

username

Stores the login username.

@Column(nullable=false,
unique=true)
privateStringusername;

The unique constraint prevents duplicate usernames.

password

Stores the encoded password.

privateStringpassword;

Never store plain-text passwords.

role

Stores the user role.

Examples:

ROLE_USER
ROLE_ADMIN
ROLE_MANAGER

Creating Repository

@Repository
publicinterfaceUserRepository
extendsJpaRepository<User,Long> {

Optional<User>findByUsername(Stringusername);

}

Why Repository is Required

Spring Security needs a way to fetch users from the database.

The method:

findByUsername(String username)

is used during authentication.

Creating Custom UserDetailsService

Spring Security uses UserDetailsService to load user information.

@Service
@RequiredArgsConstructor
publicclassCustomUserDetailsService
implementsUserDetailsService {

privatefinalUserRepositoryuserRepository;

    @Override
publicUserDetailsloadUserByUsername(
Stringusername)
throwsUsernameNotFoundException {

Useruser=userRepository
.findByUsername(username)
.orElseThrow(() ->
newUsernameNotFoundException(
"User not found"));

returnorg.springframework.security.core.userdetails
.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
    }

}

Understanding Every Line

loadUserByUsername()

Spring Security automatically calls this method during login.

loadUserByUsername(String username)

Fetching User

userRepository.findByUsername(username)

Queries the database.

UsernameNotFoundException

thrownewUsernameNotFoundException(
"User not found");

Thrown when no matching user exists.

Returning UserDetails

returnUser.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();

Spring Security converts database data into a format it understands.

PasswordEncoder Configuration

@Configuration
publicclassPasswordConfig {

    @Bean
publicPasswordEncoderpasswordEncoder() {
returnnewBCryptPasswordEncoder();
    }

}

This encoder will be used for:

  • Registration

  • Login

  • Password updates

Security Configuration

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
publicclassSecurityConfig {

privatefinalCustomUserDetailsService
userDetailsService;

    @Bean
publicSecurityFilterChainsecurityFilterChain(
HttpSecurityhttp)
throwsException {

returnhttp
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth ->auth

.requestMatchers(
"/api/public")
.permitAll()

.requestMatchers(
"/api/admin")
.hasRole("ADMIN")

.anyRequest()
.authenticated())

.userDetailsService(
userDetailsService)

.httpBasic(
Customizer.withDefaults())

.build();
    }

}

Creating User Registration Logic

When users register, passwords must be encoded.

@Service
@RequiredArgsConstructor
publicclassUserService {

privatefinalUserRepositoryrepository;
privatefinalPasswordEncoderpasswordEncoder;

publicUserregister(UserRequestrequest) {

Useruser=newUser();

user.setUsername(
request.getUsername());

user.setPassword(
passwordEncoder.encode(
request.getPassword()));

user.setRole("USER");

returnrepository.save(user);
    }
}

Example Registration Request

{
  "username":"john",
  "password":"password123"
}

Stored Database Record

Instead of:

password123

Database stores:

$2a$10$J8gfKlmN7...

This protects user credentials.

Real-World Example

Imagine an HR Management System.

Users table:

Username

Role

john

USER

admin

ADMIN

manager

MANAGER

When login occurs:

  1. Spring Security receives username.

  2. Calls CustomUserDetailsService.

  3. Fetches user from database.

  4. Validates password using BCrypt.

  5. Loads user roles.

  6. Grants access.

This is how most enterprise applications authenticate users.

Common Mistakes

Returning Entity Directly

Wrong:

returnuser;

Spring Security expects UserDetails.

Always convert entity to UserDetails.

Saving Plain Passwords

Wrong:

user.setPassword(password);

Correct:

user.setPassword(
passwordEncoder.encode(password));

Missing Username Index

Searching users without an index becomes slow.

Always make username unique.

@Column(unique=true)

Loading All Users

Wrong:

findAll()

Authentication should fetch only the required user.

Use:

findByUsername()

Production-Level Best Practices

Use Database Authentication

Avoid InMemoryUserDetailsManager in production.

Store Encoded Passwords

Always use BCrypt.

Separate Roles and Permissions

Instead of:

ADMIN
USER

Enterprise systems often use:

READ_USERS
WRITE_USERS
DELETE_USERS

for finer control.

Enable Audit Logging

Track:

  • Login attempts

  • Password changes

  • Role changes

  • Failed authentications

Use Account Locking

After multiple failed login attempts:

Account Locked

This helps prevent brute-force attacks.

Summary

In this article, we learned:

  • Why database authentication is required.

  • Creating User entities.

  • Creating UserRepository.

  • Implementing CustomUserDetailsService.

  • Loading users from a database.

  • Using BCryptPasswordEncoder.

  • User registration flow.

  • Production authentication best practices.

In Part 5, we will explore Role-Based and Authority-Based Access Control, where we will build a flexible permission system using roles, authorities, and fine-grained authorization rules used in large-scale enterprise applications.

Enjoyed this article?

Subscribe to our newsletter for more backend engineering insights and tutorials.