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:

  • AuthenticationException401 Unauthorized (not authenticated)
  • AccessDeniedException403 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Best Ways to Organize Your Freelance Workflow

Freelancing can feel like juggling a dozen balls while riding a unicycle. With the right workflow, you can keep everything moving smoothly—and stay sane.

Read more

Building a Rails API That Clients Actually Enjoy Working With

A Rails API that works correctly for the team that built it is not the same as one that's pleasant to integrate against. Here are the design decisions that determine whether clients come back with questions or with praise.

Read more

Hiring Backend Engineers in Copenhagen Means Competing With Danske Bank and Novo Nordisk — or Going Remote

Danske Bank posted the same backend role you did. They offered DKK 15K more per month, a pension you can't match, and a brand your candidate's parents have heard of.

Read more

The Backend Hiring Reality for Prague Startups That Enterprise Companies Do Not Want You to Know

Enterprise companies in Prague have spent years building advantages in the backend hiring market. Understanding how those advantages work is the first step to building around them.

Read more