Spring Security in Practice — Authentication, Authorization, and the Filters That Run on Every Request
by Eric Hanson, Backend Developer at Clean Systems Consulting
The filter chain model
Spring Security is a chain of servlet filters. Every HTTP request passes through this chain before reaching your controllers. Each filter has a specific responsibility:
Request
→ SecurityContextPersistenceFilter (loads security context)
→ UsernamePasswordAuthenticationFilter (form login)
→ BearerTokenAuthenticationFilter (JWT/OAuth2)
→ ExceptionTranslationFilter (converts exceptions to HTTP responses)
→ FilterSecurityInterceptor (authorization)
→ DispatcherServlet
→ Controller
Understanding that Spring Security is just filters explains most of its behavior: security is applied before the framework reaches your code, configuration is additive (each SecurityFilterChain bean adds filters), and disabling security for specific paths means preventing certain filters from executing for those requests.
The SecurityFilterChain bean defines which filters apply to which request matchers:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.build();
}
}
Rules are evaluated in order — the first matching rule wins. anyRequest().authenticated() must come last or it matches before more specific rules.
Authentication — how Spring Security verifies identity
Authentication answers "who is this request from?" The result is an Authentication object stored in SecurityContextHolder. Every subsequent part of the request — controllers, services, @PreAuthorize expressions — reads from this context.
JWT authentication. For stateless REST APIs, JWT authentication is implemented as a filter that runs before the authorization filter:
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = header.substring(7);
try {
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (JwtException e) {
// Invalid token — don't set authentication, let request proceed as anonymous
// The authorization filter will reject if the endpoint requires authentication
}
chain.doFilter(request, response);
}
}
Register the filter in the SecurityFilterChain:
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
The filter never rejects requests directly — it either sets the authentication or doesn't. The authorization filter (FilterSecurityInterceptor) decides whether the request is permitted based on what authentication is present.
UserDetails and UserDetailsService. UserDetailsService.loadUserByUsername() is the contract for loading user data during authentication. Implement it to load from your data store:
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.map(user -> org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPasswordHash())
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.toList())
.accountExpired(!user.isActive())
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
}
}
UserDetails carries authorities — the granted roles and permissions. These populate the Authentication object and are available to authorization decisions throughout the request.
Authorization — who can do what
Authorization answers "is this authenticated user allowed to do this?" Spring Security provides authorization at three levels: URL patterns, method-level annotations, and programmatic checks.
URL-level authorization in SecurityFilterChain:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MODERATOR")
.requestMatchers("/api/v1/orders/**").hasAuthority("ORDER_MANAGEMENT")
.anyRequest().authenticated()
)
hasRole("ADMIN") checks for authority ROLE_ADMIN. hasAuthority("ORDER_MANAGEMENT") checks for the exact authority string. URL-level authorization is coarse-grained — it applies to all requests matching the URL pattern regardless of resource ownership.
Method-level authorization with @PreAuthorize:
@Configuration
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, @Secured
public class MethodSecurityConfig {}
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public List<Order> findOrdersForUser(Long userId) {
return orderRepository.findByUserId(userId);
}
@PreAuthorize("@orderSecurityService.canModify(#orderId, authentication)")
public Order updateOrder(Long orderId, UpdateOrderRequest request) {
// Only executes if the SpEL expression returns true
}
}
@PreAuthorize uses Spring Expression Language (SpEL). #userId references the method parameter. authentication is the current Authentication object. @orderSecurityService references a Spring bean — delegate to a service for complex authorization logic that can't be expressed cleanly in SpEL.
@PostAuthorize runs after the method and can check the return value:
@PostAuthorize("returnObject.userId == authentication.principal.id or hasRole('ADMIN')")
public Order findOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
The method executes; @PostAuthorize checks whether the caller is allowed to see the result. Use it when authorization depends on the resource's data (ownership checks where you need to load the resource to check the owner).
Programmatic authorization:
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id,
@AuthenticationPrincipal UserDetails currentUser) {
Order order = orderService.findOrder(id);
// Manual check when @PreAuthorize is insufficient
if (!order.getUserId().equals(currentUser.getUsername()) &&
!currentUser.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) {
throw new AccessDeniedException("Not authorized to view this order");
}
return OrderResponse.from(order);
}
Programmatic checks belong in controllers or dedicated authorization services, not in domain services — keep the domain layer free of security concerns.
SecurityContext and @AuthenticationPrincipal
The SecurityContextHolder provides access to the current authentication anywhere in the request thread:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<GrantedAuthority> authorities = auth.getAuthorities();
In controllers, @AuthenticationPrincipal injects the current user principal directly:
@GetMapping("/profile")
public ProfileResponse getProfile(@AuthenticationPrincipal UserDetails user) {
return profileService.findProfile(user.getUsername());
}
For custom principal types (when UserDetails doesn't carry enough information), create a custom UserDetails implementation:
public class AppUserDetails implements UserDetails {
private final Long id;
private final String email;
private final String passwordHash;
private final List<GrantedAuthority> authorities;
// getters, UserDetails interface implementation...
}
// In UserDetailsService
return new AppUserDetails(user.getId(), user.getEmail(),
user.getPasswordHash(), mapAuthorities(user.getRoles()));
// In controller
@GetMapping("/profile")
public ProfileResponse getProfile(@AuthenticationPrincipal AppUserDetails user) {
return profileService.findProfile(user.getId()); // access the Long ID directly
}
CORS configuration
Cross-Origin Resource Sharing configuration in Spring Security must be set before CSRF — the CORS filter must run before the CSRF check:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // stateless API
// ...
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("https://*.example.com", "http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-ID"));
config.setExposedHeaders(List.of("X-Request-ID", "ETag"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
setAllowedOriginPatterns rather than setAllowedOrigins when using wildcard patterns — * in setAllowedOrigins doesn't combine with setAllowCredentials(true). Pattern-based allows https://*.example.com with credentials.
Exception handling — what happens when security fails
Spring Security's ExceptionTranslationFilter translates security exceptions to HTTP responses:
AuthenticationException→401 Unauthorized(not authenticated)AccessDeniedException→403 Forbidden(authenticated but not authorized)
Customize the responses:
http.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("""
{"errors": [{"code": "authentication_required",
"message": "Authentication is required"}]}
""");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write("""
{"errors": [{"code": "access_denied",
"message": "You do not have permission to access this resource"}]}
""");
})
);
Without custom handlers, Spring Security returns HTML error pages — wrong for REST APIs.
Password encoding
BCryptPasswordEncoder is the standard for password hashing in Spring Security:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength 12 — ~250ms per hash on modern hardware
}
// Storing a password
String hash = passwordEncoder.encode(rawPassword);
user.setPasswordHash(hash);
// Verifying a password
boolean valid = passwordEncoder.matches(rawPassword, user.getPasswordHash());
BCrypt strength 12 produces a hash that takes ~250ms to compute on modern hardware — slow enough to resist brute force, fast enough for authentication. Strength 10 (the default) takes ~100ms; strength 14 takes ~1 second.
DelegatingPasswordEncoder allows migrating between encoding strategies while keeping backwards compatibility with existing hashes — appropriate for applications upgrading from MD5 or SHA-1 to BCrypt.
Testing secured endpoints
@WithMockUser sets a mock security context for tests without authenticating:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@Test
@WithMockUser(username = "alice@example.com", roles = {"USER"})
void getOrder_returnsOrder_forAuthenticatedUser() throws Exception {
// Security context has USER role — endpoint permits this
mockMvc.perform(get("/api/v1/orders/123"))
.andExpect(status().isOk());
}
@Test
void getOrder_returns401_withoutAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/orders/123"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = {"USER"})
void deleteOrder_returns403_withoutAdminRole() throws Exception {
mockMvc.perform(delete("/api/v1/orders/123"))
.andExpect(status().isForbidden());
}
}
@WithMockUser works with @WebMvcTest without loading the full security configuration — the mock user bypasses the authentication filter chain. For integration tests that should exercise the full filter chain, use @SpringBootTest with actual JWT tokens.
For @AuthenticationPrincipal with custom types, create a custom @WithMockUser equivalent using @WithSecurityContext.