Authentication & Authorization
UserDetailsService, password encoding with BCrypt, role-based authorization, and method security with @PreAuthorize.
UserDetailsService
Spring Security authenticates against its own UserDetails abstraction, not against your entity classes directly. You implement UserDetailsService to bridge the two — loading a user (however your application stores one) and adapting it into the shape Spring Security expects:
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public AppUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("No user with email: " + email));
return org.springframework.security.core.userdetails.User
.withUsername(user.getEmail())
.password(user.getHashedPassword())
.authorities(user.getRoles().stream().map(SimpleGrantedAuthority::new).toList())
.build();
}
}
Spring Security calls this automatically during authentication — you never call it yourself.
Password encoding
Passwords are never stored or compared in plain text. BCryptPasswordEncoder hashes with a random per-password salt baked into the output, so two identical passwords produce different hashes:
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Service
public class RegistrationService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public RegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
}
public User register(String email, String rawPassword) {
String hashed = passwordEncoder.encode(rawPassword);
return userRepository.save(new User(email, hashed));
}
}
Authentication compares a submitted password against the stored hash with passwordEncoder.matches(raw, hashed) — Spring Security's authentication provider does this for you once UserDetailsService and PasswordEncoder beans are both registered; you don't call matches directly in ordinary login flows.
Role-based authorization
Roles/authorities can be checked at the URL level (in the SecurityFilterChain) or at the method level.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "MANAGER")
.anyRequest().authenticated()
);
.hasRole("ADMIN") is shorthand that expects the authority to be stored as ROLE_ADMIN — Spring Security adds the ROLE_ prefix automatically when you use hasRole (but not when checking a raw authority with hasAuthority).
Method security with @PreAuthorize
URL-level rules are coarse — they don't know about a specific resource being requested. Method security expresses authorization rules directly on service or controller methods, evaluated with a Spring Expression Language (SpEL) condition before the method body ever runs:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteDocument(Long id) {
// only reachable if the caller has ROLE_ADMIN
}
@PreAuthorize("#ownerId == authentication.principal.id or hasRole('ADMIN')")
public Document getDocument(Long documentId, Long ownerId) {
// reachable by the resource's own owner, OR an admin
return documentRepository.findById(documentId).orElseThrow();
}
}
@PreAuthorize runs before the method executes and can reference method parameters directly (#ownerId above) — useful for exactly the kind of per-resource, ownership-based check that a URL pattern alone can't express. @PostAuthorize is the equivalent check evaluated after the method returns, letting the expression inspect the returned object itself (returnObject.ownerId == authentication.principal.id).
Common mistakes
- Comparing raw passwords with
.equals()instead ofpasswordEncoder.matches(...)— plaintext comparison defeats the entire purpose of hashing and is a critical security bug. - Forgetting
@EnableMethodSecurity— without it,@PreAuthorize/@PostAuthorizeannotations are silently ignored and every method runs unguarded. - Using
hasRole("ADMIN")when the stored authority is literally"ADMIN"rather than"ROLE_ADMIN"(or vice versa) —hasRolealways adds theROLE_prefix implicitly, so a mismatch here silently denies everyone. - Relying only on URL-level authorization for resource-ownership checks (e.g. "users can only edit their own profile") — a URL pattern like
/api/users/{id}can't express "only if{id}belongs to the caller"; that needs method security or an explicit check in the handler.