Spring Security Patterns
Custom UserDetailsService
@Service
@RequiredArgsConstructor
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.map(this::toUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
}
private UserDetails toUserDetails(User user) {
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getEmail(), user.getPasswordHash(), user.isActive(), true, true, true, authorities);
}
}
OAuth2 Resource Server (JWT)
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().hasAuthority("SCOPE_api")
)
.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
}
Custom Authentication Filter
public class ApiKeyAuthFilter extends OncePerRequestFilter {
private static final String API_KEY_HEADER = "X-API-Key";
private final ApiKeyService apiKeyService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String apiKey = request.getHeader(API_KEY_HEADER);
if (apiKey != null) {
apiKeyService.validateKey(apiKey).ifPresent(principal -> {
var auth = new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
});
}
chain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return new AntPathMatcher().match("/public/**", request.getServletPath());
}
}
// Register in SecurityFilterChain
http.addFilterBefore(apiKeyAuthFilter(), UsernamePasswordAuthenticationFilter.class);
Security Context Access
// In any Spring bean
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
// In controller via @AuthenticationPrincipal
@GetMapping("/me")
public UserResponse getCurrentUser(@AuthenticationPrincipal JwtAuthenticationToken token) {
String userId = token.getToken().getSubject();
return userService.findBySubject(userId);
}
// With custom principal
@GetMapping("/profile")
public Profile getProfile(@AuthenticationPrincipal AppUserPrincipal principal) {
return profileService.findByUserId(principal.getUserId());
}
Request-Level Authorization with SpEL
.authorizeHttpRequests(auth -> auth
// Only users accessing their own data
.requestMatchers("/api/users/{id}/**").access(
new WebExpressionAuthorizationManager("#id == authentication.name or hasRole('ADMIN')")
)
// IP allowlist
.requestMatchers("/actuator/**").access(
new WebExpressionAuthorizationManager("hasIpAddress('10.0.0.0/8')")
)
)
Security Event Listening
@Component
public class AuthenticationEventListener {
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
log.info("Login success: {}", event.getAuthentication().getName());
}
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
log.warn("Login failure for {}: {}", event.getAuthentication().getName(),
event.getException().getMessage());
auditService.recordFailedLogin(event.getAuthentication().getName());
}
}
Key Rules
- Clear
SecurityContextHolder after async processing — it's ThreadLocal and leaks across thread boundaries
- Never expose internal user IDs in JWT subjects; use opaque UUIDs or email
- Use
@AuthenticationPrincipal in controllers instead of calling SecurityContextHolder directly
- Rate-limit authentication endpoints independently — Spring Security has no built-in rate limiting
hasAuthority('ROLE_X') and hasRole('X') are equivalent — hasRole auto-prefixes ROLE_; be consistent