Spring Security tutorials usually stop once you can log in with an in-memory user. But in a real application, users are stored in a users table in your database, right? When you try to move beyond that to “let users log in with the username and password stored in my own table,” the cast of characters suddenly grows and it’s easy to get lost.

In this article, we’ll walk through the standard patterns for authenticating against users persisted in a database, narrowed down to two routes. One is writing your own UserDetailsService; the other is JdbcUserDetailsManager, which uses the standard schema. Along the way, we’ll also cover registering and verifying passwords with BCrypt, and how to handle account deactivation.

The basics of in-memory configuration and form login are covered in Implementing Basic Authentication with Spring Security, so we’ll assume you’re familiar with that.

The Big Picture of DB Authentication

Understanding how authentication flows first makes the code that follows much easier to read. For form-based authentication, it roughly works like this:

AuthenticationManager
  → DaoAuthenticationProvider
      → UserDetailsService(DBからユーザーを引く)
      → PasswordEncoder(パスワードを照合する)

DaoAuthenticationProvider splits the work between “fetching user information” and “verifying the password.” The former is handled by UserDetailsService, the latter by PasswordEncoder.

There are two routes for authenticating against users in the database.

  • Custom route: Implement UserDetailsService to match your own schema. You’re free to decide your table structure and columns.
  • Standard route: Use JdbcUserDetailsManager, which assumes the standard schema bundled with Spring Security. You barely have to write any code.

The rough guideline is: go custom if you want extensibility, standard if you want to get it done quickly. Let’s look at each in turn.

Designing the users Table and Roles

With the custom route, you start by deciding on the schema. It’s natural to separate the user itself from its roles.

CREATE TABLE users (
    id       BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50)  NOT NULL UNIQUE,
    password VARCHAR(72)  NOT NULL,
    enabled  BOOLEAN      NOT NULL DEFAULT TRUE
);

CREATE TABLE user_roles (
    user_id BIGINT      NOT NULL,
    role    VARCHAR(50) NOT NULL,
    PRIMARY KEY (user_id, role),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

The key point is the length of the password column. A BCrypt hash comes out to around 70 characters including the {bcrypt} prefix, so reserving something like VARCHAR(72) is safe. You must never store plaintext here.

Put a unique constraint on username. Since it’s the column you search on every login, an index naturally kicks in as well. As for roles, you have two options: store them with the prefix included, like ROLE_ADMIN, or add the prefix on the code side. Here we’ll assume the code side adds it, and store roles short, like ADMIN.

Preparing the Entity and Repository

This is the persistence layer that pulls users from the database. We’ll show an example implementation with JPA, but we’ll leave the details of writing repositories to Introduction to JPA Query Methods and cover only the essentials here.

@Entity
@Table(name = "users")
public class UserEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private boolean enabled;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"))
    @Column(name = "role")
    private Set<String> roles = new HashSet<>();
    // getter/setter は省略
}

Roles are fetched all at once with EAGER. Since roles are always needed at authentication time, it’s simpler to just pull them in together rather than making them lazy-loaded and then struggling with N+1 problems or out-of-session access.

public interface UserRepository extends JpaRepository<UserEntity, Long> {
    Optional<UserEntity> findByUsername(String username);
}

Implementing UserDetails

Spring Security can’t understand your custom entity directly. You need to convert it into the UserDetails interface. It works even if you make the entity implements it, but since you don’t want to mix domain and security concerns, wrapping it in a dedicated adapter is recommended.

public class CustomUserDetails implements UserDetails {
    private final UserEntity user;

    public CustomUserDetails(UserEntity user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
            .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
            .toList();
    }

    @Override public String getUsername() { return user.getUsername(); }
    @Override public String getPassword() { return user.getPassword(); }
    @Override public boolean isEnabled() { return user.isEnabled(); }

    @Override public boolean isAccountNonLocked()     { return true; }
    @Override public boolean isAccountNonExpired()    { return true; }
    @Override public boolean isCredentialsNonExpired(){ return true; }
}

Adding the ROLE_ prefix here is important. hasRole("ADMIN") internally looks for an authority called ROLE_ADMIN, so the division of labor is: store ADMIN in the database, and add ROLE_ when converting to an authority.

The four flags such as isEnabled will each reject authentication with a corresponding exception if they return false. A realistic starting point is to link only enabled with the database and leave the rest fixed at true for now.

Writing Your Own UserDetailsService

Now that the players are in place, let’s write the core that looks up the user from the database in loadUserByUsername.

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) {
        UserEntity user = userRepository.findByUsername(username)
            .orElseThrow(() ->
                new UsernameNotFoundException("ユーザーが見つかりません"));
        return new CustomUserDetails(user);
    }
}

When the user isn’t found, throw UsernameNotFoundException. What you want to be careful about here is not distinguishing the message between “the user doesn’t exist” and “the password is wrong.” If you distinguish them, you’re telling an attacker whether a username exists. Spring Security lumps both cases together as BadCredentials with the same message, so there’s no need for you to helpfully differentiate them.

The reason for adding @Transactional(readOnly = true) is to ensure roles can be fetched safely even if you made them lazy-loaded.

Connecting to DaoAuthenticationProvider

All that’s left is to register the beans. This is the part that’s gotten the easiest in modern Spring Security: if you leave a UserDetailsService and a PasswordEncoder as beans, DaoAuthenticationProvider is assembled automatically.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form.loginPage("/login").permitAll());
        return http.build();
    }

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

Since CustomUserDetailsService is already registered with @Service, there’s no need to explicitly assemble a DaoAuthenticationProvider. Only when you have a special reason—such as wanting to use multiple providers selectively—do you new up a DaoAuthenticationProvider yourself, call setUserDetailsService and setPasswordEncoder, and register it with the AuthenticationManager.

Registering and Verifying with BCrypt

The key point is that we used createDelegatingPasswordEncoder() for the PasswordEncoder. This is a mechanism that looks at a prefix like {bcrypt} at the beginning of the hash and switches the algorithm used for verification accordingly.

When registering a user, encode the plaintext before saving.

@Service
public class UserRegistrationService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    // コンストラクタ省略

    public void register(String username, String rawPassword) {
        UserEntity user = new UserEntity();
        user.setUsername(username);
        user.setPassword(passwordEncoder.encode(rawPassword));
        user.setEnabled(true);
        user.getRoles().add("USER");
        userRepository.save(user);
    }
}

The result of encode is a string like {bcrypt}$2a$10$..., and you save that as-is to the database. At login time, matches is called automatically on the verification side, which hashes the entered plaintext with the same salt and compares. You must never store plaintext, because the moment the database leaks, every user’s password can be used. With BCrypt, you can’t reverse the hash back to the original.

If you already have data stored in plaintext, a safer migration than re-encodeing everything in bulk is to add a {noop} prefix to treat the existing data as plaintext, then re-encode it to BCrypt on the next successful login.

Using the Standard Schema with JdbcUserDetailsManager

If you don’t need flexibility in the schema, there’s an option that requires almost no code. Spring Security bundles a DDL for the standard users/authorities schema, and provides JdbcUserDetailsManager built on that assumption.

@Bean
public UserDetailsManager userDetailsManager(DataSource dataSource) {
    return new JdbcUserDetailsManager(dataSource);
}

The standard schema has a fixed structure of users(username, password, enabled) and authorities(username, authority). The DDL is included in the classpath at org/springframework/security/core/userdetails/jdbc/users.ddl, so if you create the tables with it, everything works out of the box. Management APIs like createUser, updateUser, and deleteUser are also available, which is handy when building a user management screen.

The criteria for choosing between them are simple. If you have requirements like adding your own columns such as an email address or display name to the users table, or getting creative with how roles are held, go with the custom route. If the standard schema is enough as-is, JdbcUserDetailsManager is a fine call.

Account Deactivation and Login Failures

The four flags of UserDetails each correspond to a dedicated exception. If isEnabled() is false, a DisabledException is thrown; if isAccountNonLocked() is false, a LockedException is thrown, and authentication fails. If you express withdrawal or suspension with the enabled column, that user can no longer log in.

The behavior on login failure can be adjusted in the form login configuration.

.formLogin(form -> form
    .loginPage("/login")
    .failureUrl("/login?error")
    .permitAll())

Since it redirects to /login?error, you check the error parameter on the template side and display a message. Here too, avoid specific messages like “the account doesn’t exist,” and it’s safest to standardize on “the username or password is incorrect.”

After authentication succeeds, when you want to control access based on roles, hasRole / hasAuthority assume that authorities are granted with the ROLE_ prefix included. Method-level control is covered in Method Security with @PreAuthorize.

Summary

We’ve looked at two routes for authenticating against users stored in a database. If you need a custom schema or extensions, write your own UserDetailsService, wrap the entity with CustomUserDetails, and connect it to DaoAuthenticationProvider. If the standard schema is enough, JdbcUserDetailsManager lets you keep the code to a minimum.

With either route, the basics are the same: hash passwords with BCrypt, and manage account state with flags like enabled. Once you’ve built this far, you can naturally move on to authorization with method security, or advancing to Stateless Authentication with JWT.