Users and Password Encoding

Load users from your own database with a UserDetailsService and store passwords with a slow, salted, upgradeable hash.

Syntax@Bean UserDetailsService users(UserRepository repo) { return username -> ...; } @Bean PasswordEncoder encoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } String hash = encoder.encode(raw); // "{bcrypt}..."

For username and password login, Spring Security needs two things from you: a way to find a user by name, and a way to check a password. These are the UserDetailsService and the PasswordEncoder. Declare one bean of each and Spring Security wires them into a DaoAuthenticationProvider for you.

Loading users

UserDetailsService has one method, loadUserByUsername. Return a UserDetails (the built-in User class is fine) holding the stored password hash and the user's authorities, or throw UsernameNotFoundException. Spring Security then compares the submitted password with the hash. Your code never compares passwords itself.

InMemoryUserDetailsManager is handy for tests and demos. Real applications read from their own users table with a repository, which keeps users in the same place as the rest of your data and lets you add fields such as an id or a tenant.

Hashing passwords

Never store a password, and never store a fast hash of one such as SHA-256: a leaked table of fast hashes is cracked within hours on a graphics card. Use a deliberately slow, salted algorithm. The recommended bean is the delegating encoder:

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

It writes hashes with a prefix naming the algorithm, such as {bcrypt}$2a$10$..., and can read any algorithm it knows. That prefix is what lets you change algorithm or strength later without forcing every user to reset their password.

Upgrading old hashes

Implement UserDetailsPasswordService on the same bean and Spring Security calls it after a successful login whenever the stored hash is weaker than the current default. You write the new hash to the database; users upgrade silently the next time they log in.

Mistakes to avoid

  • NoOpPasswordEncoder or {noop} outside tests. It stores the password as typed.
  • Different messages for an unknown user and a wrong password. That tells an attacker which accounts exist. Spring Security returns the same BadCredentialsException for both by default; do not undo that in your error handler.
  • Very long passwords with bcrypt. Bcrypt only uses the first 72 bytes, and Spring Security's BCryptPasswordEncoder refuses longer input with an IllegalArgumentException. Cap the length in your registration validation, in UTF-8 bytes rather than characters, so the user gets a 400 instead of a 500.
  • Logging the request body of a login or registration endpoint. Passwords end up in your log store.

Hashing is meant to be slow, roughly tens of milliseconds per check. That is a feature, but it also means HTTP Basic re-hashes on every request; for busy APIs, authenticate once and use tokens.

Example

Example · java
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Entity
@Table(name = "app_user")
class AppUser {
    @Id
    @GeneratedValue
    Long id;

    @Column(nullable = false, unique = true)
    String email;

    @Column(nullable = false)
    String passwordHash;

    @Column(nullable = false)
    String role;

    boolean enabled = true;

    protected AppUser() {
    }

    AppUser(String email, String passwordHash, String role) {
        this.email = email;
        this.passwordHash = passwordHash;
        this.role = role;
    }
}

interface AppUserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByEmailIgnoreCase(String email);
}

@Configuration
public class UserSecurityConfig {

    @Bean
    UserDetailsService userDetailsService(AppUserRepository users) {
        return email -> users.findByEmailIgnoreCase(email)
                .map(u -> User.withUsername(u.email)
                        .password(u.passwordHash)   // the stored hash, never the raw password
                        .roles(u.role)              // becomes ROLE_<role>
                        .disabled(!u.enabled)
                        .build())
                .orElseThrow(() -> new UsernameNotFoundException("not found"));
    }

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

When to use it

  • Authenticate API clients against an existing users table that also stores tenant and account status.
  • Move a legacy system from salted SHA-256 hashes to bcrypt without forcing a password reset.
  • Refuse breached passwords at sign-up for a consumer-facing service.

More examples

Registering a user

The service encodes the password once and stores only the result. The validation caps length at 72 characters, which also stops someone posting a megabyte-long password to burn CPU. Characters are not bytes, though: accented letters and emoji take several bytes each, so the service checks the UTF-8 length too, because the bcrypt encoder throws for anything over 72 bytes. A unique index on <code>email</code> in the database is what really prevents duplicates; checking first and inserting second races under load.

Example · java
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

record RegistrationRequest(
        @NotBlank @Email String email,
        @NotBlank @Size(min = 12, max = 72) String password) {
}

@Service
public class RegistrationService {

    private final JdbcClient jdbc;
    private final PasswordEncoder passwordEncoder;

    RegistrationService(JdbcClient jdbc, PasswordEncoder passwordEncoder) {
        this.jdbc = jdbc;
        this.passwordEncoder = passwordEncoder;
    }

    @Transactional
    public long register(RegistrationRequest request) {
        // @Size counts characters; bcrypt's limit is 72 bytes of UTF-8.
        if (request.password().getBytes(StandardCharsets.UTF_8).length > 72) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password is too long.");
        }
        String hash = passwordEncoder.encode(request.password());
        return jdbc.sql("""
                insert into app_user(email, password_hash, role, enabled)
                values (:email, :hash, 'USER', true)
                returning id""")
                .param("email", request.email().trim().toLowerCase())
                .param("hash", hash)
                .query(Long.class)
                .single();
    }
}

Upgrading hashes on login

Implementing both interfaces on one bean is enough: Spring Security detects the <code>UserDetailsPasswordService</code> and calls it when <code>PasswordEncoder.upgradeEncoding</code> reports that a hash is out of date, for example an old <code>{sha256}</code> hash or a bcrypt hash with a lower cost. Over a few weeks of normal logins most of the table is rehashed with no reset emails.

Example · java
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsPasswordService;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

record UserRow(String email, String passwordHash, String role) {
}

@Service
public class JdbcUserService implements UserDetailsService, UserDetailsPasswordService {

    private final JdbcClient jdbc;

    JdbcUserService(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Override
    public UserDetails loadUserByUsername(String email) {
        UserRow row = jdbc.sql("select email, password_hash, role from app_user where email = :email")
                .param("email", email.toLowerCase())
                .query(UserRow.class)
                .optional()
                .orElseThrow(() -> new UsernameNotFoundException("not found"));
        return User.withUsername(row.email()).password(row.passwordHash()).roles(row.role()).build();
    }

    @Override
    public UserDetails updatePassword(UserDetails user, String newEncodedPassword) {
        // Called after a successful login when the stored hash is outdated.
        jdbc.sql("update app_user set password_hash = :hash where email = :email")
                .param("hash", newEncodedPassword)
                .param("email", user.getUsername())
                .update();
        return User.withUserDetails(user).password(newEncodedPassword).build();
    }
}

Rejecting passwords known from breaches

Length rules matter less than refusing passwords that are already in attackers' word lists. The Have I Been Pwned checker uses k-anonymity: it sends a five-character hash prefix and compares the returned suffixes locally. With a <code>CompromisedPasswordChecker</code> bean present, Spring Security also checks passwords at login. The call goes over the network, so decide how registration behaves when that service is unreachable.

Example · java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.password.CompromisedPasswordChecker;
import org.springframework.security.authentication.password.CompromisedPasswordDecision;
import org.springframework.security.web.authentication.password.HaveIBeenPwnedRestApiPasswordChecker;
import org.springframework.stereotype.Component;

@Configuration
class PasswordCheckConfig {

    @Bean
    CompromisedPasswordChecker compromisedPasswordChecker() {
        // Sends only the first 5 characters of the SHA-1 hash, never the password.
        return new HaveIBeenPwnedRestApiPasswordChecker();
    }
}

@Component
public class PasswordPolicy {

    private final CompromisedPasswordChecker checker;

    PasswordPolicy(CompromisedPasswordChecker checker) {
        this.checker = checker;
    }

    public void requireSafe(String rawPassword) {
        CompromisedPasswordDecision decision = checker.check(rawPassword);
        if (decision.isCompromised()) {
            throw new IllegalArgumentException("This password appears in a known data breach. Choose another.");
        }
    }
}

Discussion

  • Be the first to comment on this lesson.