Spring Security Fundamentals: Filter Chain, Authentication, and Authorization

Spring Security is powerful but famously hard to understand. This article demystifies the core: the filter chain, how requests are processed, and how authentication and authorization work before writing a line of security config.

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

The moment you add this dependency, Spring Boot’s auto-configuration secures all endpoints with HTTP Basic authentication. A random password is printed at startup. This is the starting point — you’ll replace the defaults.

The Security Filter Chain

Spring Security is a chain of servlet filters that runs before your controller:

HTTP Request arrives
        │
        ▼
SecurityContextPersistenceFilter   ← loads SecurityContext from session
        │
        ▼
UsernamePasswordAuthenticationFilter  ← processes login form submissions
        │
        ▼
BearerTokenAuthenticationFilter       ← processes JWT Bearer tokens
        │
        ▼
ExceptionTranslationFilter            ← converts security exceptions to HTTP responses
        │
        ▼
AuthorizationFilter                   ← checks if the request is authorized
        │
        ├─ Authorized → your @Controller handles the request
        │
        └─ Not authorized → 401/403 response

Each filter can either:

  • Pass the request to the next filter
  • Short-circuit (return a response immediately)
  • Throw an exception (caught by ExceptionTranslationFilter)

You configure the filter chain through a SecurityFilterChain bean.

Authentication vs Authorization

Authentication: Who are you? (identity verification)
Authorization: What are you allowed to do? (permission check)

Spring Security handles both:

  • Authentication: verifies credentials (username+password, JWT, API key, OAuth token)
  • Authorization: checks roles, permissions, or custom rules against the authenticated identity

The SecurityContext

After successful authentication, Spring stores the identity in SecurityContextHolder:

// Get the current user anywhere in your application
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

String username = auth.getName();
Collection<? extends GrantedAuthority> roles = auth.getAuthorities();
Object principal = auth.getPrincipal();  // usually UserDetails or a custom object

// In a controller
@GetMapping("/api/me")
public UserResponse getCurrentUser(Principal principal) {
    return userService.findByUsername(principal.getName());
}

// Or with @AuthenticationPrincipal
@GetMapping("/api/me")
public UserResponse getCurrentUser(@AuthenticationPrincipal UserDetails user) {
    return UserResponse.from(user);
}

The SecurityContext is stored in a ThreadLocal by default — bound to the current request thread.

Configuring the SecurityFilterChain

Replace the auto-configured security with your own:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
            // Disable CSRF for REST APIs (stateless — no session cookies)
            .csrf(csrf -> csrf.disable())

            // Session management: stateless (JWT-based, no server-side sessions)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // Authorization rules
            .authorizeHttpRequests(auth -> auth
                // Public endpoints — no authentication required
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()

                // Require ADMIN role
                .requestMatchers("/api/admin/**").hasRole("ADMIN")

                // Require MANAGER or ADMIN for certain operations
                .requestMatchers(HttpMethod.DELETE, "/api/orders/**")
                    .hasAnyRole("ADMIN", "MANAGER")

                // Everything else requires authentication
                .anyRequest().authenticated()
            )

            // Error handling
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                .accessDeniedHandler(
                    (req, res, e) -> res.sendError(HttpServletResponse.SC_FORBIDDEN))
            );

        return http.build();
    }
}

UserDetails — The Core Authentication Contract

Spring Security’s authentication is built around UserDetails:

public interface UserDetails {
    String getUsername();
    String getPassword();
    Collection<? extends GrantedAuthority> getAuthorities();  // roles/permissions
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

Implement it with your User entity:

@Entity
@Table(name = "users")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

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

    @Column(nullable = false)
    private String password;  // hashed

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_roles")
    @Column(name = "role")
    private Set<String> roles = new HashSet<>();

    private boolean enabled = true;
    private boolean accountNonLocked = true;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .toList();
    }

    @Override public boolean isAccountNonExpired() { return true; }
    @Override public boolean isAccountNonLocked() { return accountNonLocked; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled() { return enabled; }

    // other getters
}

UserDetailsService — Loading Users

Spring Security calls UserDetailsService.loadUserByUsername() during authentication:

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        return userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(
                "User not found: " + username));
    }
}

Register it in the security config:

@Bean
public AuthenticationManager authenticationManager(
        UserDetailsService userDetailsService,
        PasswordEncoder passwordEncoder) {

    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userDetailsService);
    provider.setPasswordEncoder(passwordEncoder);

    return new ProviderManager(provider);
}

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12);  // cost factor 12
}

Authorization: Roles and Authorities

Spring Security distinguishes roles and authorities:

  • Authority: a permission string (e.g., READ_ORDERS, WRITE_PRODUCTS)
  • Role: a special authority with the ROLE_ prefix (e.g., ROLE_ADMIN)
// hasRole("ADMIN") checks for GrantedAuthority "ROLE_ADMIN"
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// hasAuthority("READ_ORDERS") checks for GrantedAuthority "READ_ORDERS" exactly
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("READ_ORDERS")

// access() with SpEL for complex rules
.requestMatchers("/api/orders/{id}").access(
    "@orderSecurityService.canAccess(authentication, #id)")

Method-Level Security

Protect service methods directly with annotations:

@Configuration
@EnableMethodSecurity  // enables @PreAuthorize, @PostAuthorize, @Secured
public class MethodSecurityConfig {}

@Service
public class OrderService {

    @PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(authentication, #id)")
    public Order findById(UUID id) { ... }

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteOrder(UUID id) { ... }

    @PostAuthorize("returnObject.customerId == authentication.principal.id")
    public Order getOrder(UUID id) { ... }

    @Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
    public List<Order> findAll() { ... }
}
@Component("orderSecurity")
public class OrderSecurityService {

    private final OrderRepository orderRepository;

    public boolean isOwner(Authentication auth, UUID orderId) {
        UserDetails user = (UserDetails) auth.getPrincipal();
        return orderRepository.findById(orderId)
            .map(order -> order.getCustomerUsername().equals(user.getUsername()))
            .orElse(false);
    }
}

What Happens When Security Fails

When authentication or authorization fails, ExceptionTranslationFilter handles it:

  • AuthenticationException → calls AuthenticationEntryPoint → typically 401
  • AccessDeniedException → calls AccessDeniedHandler → typically 403

Configure them:

.exceptionHandling(ex -> ex
    .authenticationEntryPoint((request, response, authException) -> {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("""
            {
              "type": "https://devopsmonk.com/errors/unauthorized",
              "title": "Unauthorized",
              "status": 401,
              "detail": "%s"
            }
            """.formatted(authException.getMessage()));
    })
    .accessDeniedHandler((request, response, accessDeniedException) -> {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write("""
            {
              "type": "https://devopsmonk.com/errors/forbidden",
              "title": "Forbidden",
              "status": 403,
              "detail": "You don't have permission to access this resource"
            }
            """);
    })
)

Multiple Security Filter Chains

For APIs with different security requirements (public API vs admin API):

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Admin API — higher security
    @Bean
    @Order(1)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/admin/**")
            .authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"))
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    // Public API — JWT-based
    @Bean
    @Order(2)
    public SecurityFilterChain apiChain(HttpSecurity http,
                                         JwtAuthenticationFilter jwtFilter) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

What You’ve Learned

  • Spring Security runs as a chain of servlet filters before your controllers
  • SecurityContextHolder holds the authenticated identity for the current thread
  • Configure security with a SecurityFilterChain bean — disable CSRF for REST APIs, use STATELESS sessions
  • UserDetails + UserDetailsService are the core authentication contracts
  • GrantedAuthority represents permissions; roles are ROLE_ prefixed authorities
  • @PreAuthorize / @PostAuthorize enable method-level security
  • AuthenticationEntryPoint handles 401; AccessDeniedHandler handles 403

Next: Article 24 — Password Encoding and User Authentication — BCrypt, registering users, and the login endpoint.