Spring BootSecurity Configuration
Spring Boot

Security Configuration

Spring Security configuration centres on the SecurityFilterChain bean. Every HTTP request passes through an ordered chain of filters before reaching a controller. This entry covers the full SecurityFilterChain setup, CORS configuration, CSRF protection, security headers, multiple filter chains for mixed authentication strategies, and actuator security.

SecurityFilterChain Setup

SecurityFilterChain is the central Spring Security configuration bean. Declare it in a @Configuration class annotated with @EnableWebSecurity. Every request passes through the chain in declaration order — the first matching requestMatcher wins. Always define the most specific rules before the catch-all anyRequest().
Java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {

    private final CustomUserDetailsService  userDetailsService;
    private final JwtAuthenticationFilter   jwtFilter;
    private final JwtAuthenticationEntryPoint authEntryPoint;
    private final JwtAccessDeniedHandler    accessDeniedHandler;
    private final PasswordEncoder           passwordEncoder;

    // ── Authentication provider ────────────────────────────────────────
    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider =
            new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        provider.setHideUserNotFoundExceptions(true);
        return provider;
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    // ── Main filter chain ──────────────────────────────────────────────
    @Bean
    @Order(2)
    public SecurityFilterChain apiFilterChain(HttpSecurity http)
            throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigSource()))
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(
                    SessionCreationPolicy.STATELESS))
            .authenticationProvider(authenticationProvider())
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authEntryPoint)
                .accessDeniedHandler(accessDeniedHandler))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/api/v1/auth/**",
                    "/api/v1/public/**"
                ).permitAll()
                .requestMatchers("/api/v1/admin/**")
                    .hasRole("ADMIN")
                .requestMatchers(
                    HttpMethod.GET, "/api/v1/**")
                    .hasAnyRole("USER","MANAGER","ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter,
                UsernamePasswordAuthenticationFilter.class)
            .headers(headers -> headers
                .frameOptions(
                    HeadersConfigurer.FrameOptionsConfig::deny)
                .contentTypeOptions(Customizer.withDefaults())
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000)))
            .build();
    }
}

CORS Configuration

CORS (Cross-Origin Resource Sharing) headers tell browsers which origins are permitted to call the API. Configure it through a CorsConfigurationSource bean and wire it into the filter chain — never rely on @CrossOrigin annotations alone for a consistent security posture. Use environment-specific allowed origins to prevent production APIs from accepting localhost calls.
Java
@Configuration
public class CorsConfig {

    @Value("${app.cors.allowed-origins}")
    private List<String> allowedOrigins;

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        // ── Allowed origins (never use * in production) ────────────────
        config.setAllowedOrigins(allowedOrigins);
        // Or pattern-based:
        // config.setAllowedOriginPatterns(
        //     List.of("https://*.myapp.com"));

        // ── Allowed HTTP methods ───────────────────────────────────────
        config.setAllowedMethods(List.of(
            "GET","POST","PUT","PATCH","DELETE","OPTIONS","HEAD"));

        // ── Allowed headers ───────────────────────────────────────────
        config.setAllowedHeaders(List.of(
            HttpHeaders.AUTHORIZATION,
            HttpHeaders.CONTENT_TYPE,
            HttpHeaders.ACCEPT,
            HttpHeaders.ORIGIN,
            "X-Requested-With",
            "X-API-Version",
            "X-Correlation-ID"));

        // ── Expose headers the browser JS can read ────────────────────
        config.setExposedHeaders(List.of(
            HttpHeaders.AUTHORIZATION,
            "X-Correlation-ID",
            "X-Rate-Limit-Remaining"));

        // ── Allow cookies / Authorization header ──────────────────────
        config.setAllowCredentials(true);

        // ── Preflight cache duration ───────────────────────────────────
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source =
            new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

# ── application.yml ────────────────────────────────────────────────────
app:
  cors:
    allowed-origins:
      - https://myapp.com
      - https://www.myapp.com

# ── application-dev.yml ───────────────────────────────────────────────
app:
  cors:
    allowed-origins:
      - http://localhost:3000
      - http://localhost:5173
      - http://127.0.0.1:3000

CSRF Protection

CSRF protection is essential for stateful session-based applications but unnecessary for stateless JWT APIs — the absence of cookies means there is nothing for a CSRF attack to exploit. For SPAs that use cookie-based sessions, use the CookieCsrfTokenRepository so the SPA can read the token from a JavaScript-accessible cookie.
Java
// ── Stateless REST API — disable CSRF entirely ───────────────────────
@Bean
public SecurityFilterChain apiChain(HttpSecurity http)
        throws Exception {
    return http
        .csrf(AbstractHttpConfigurer::disable)   // JWT in header — no CSRF
        .sessionManagement(sm -> sm
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .build();
}

// ── SPA with cookie sessions — CookieCsrfTokenRepository ─────────────
@Bean
public SecurityFilterChain spaChain(HttpSecurity http)
        throws Exception {
    return http
        .csrf(csrf -> csrf
            // Set XSRF-TOKEN cookie readable by JS
            .csrfTokenRepository(
                CookieCsrfTokenRepository.withHttpOnlyFalse())
            // Defer CSRF token loading until it is needed
            .csrfTokenRequestHandler(
                new XorCsrfTokenRequestAttributeHandler()))
        // SPA reads XSRF-TOKEN cookie and sends it as X-XSRF-TOKEN
        // header on every mutating request (POST/PUT/PATCH/DELETE)
        .build();
}

// ── Exclude specific paths from CSRF (e.g. webhooks) ─────────────────
@Bean
public SecurityFilterChain mixedChain(HttpSecurity http)
        throws Exception {
    return http
        .csrf(csrf -> csrf
            .csrfTokenRepository(
                CookieCsrfTokenRepository.withHttpOnlyFalse())
            .ignoringRequestMatchers(
                "/api/v1/webhooks/**",    // signed with HMAC — no CSRF
                "/api/v1/payments/ipn"))  // Stripe/PayPal IPN
        .build();
}

Security Headers

HTTP security headers instruct browsers to enforce additional protections. Spring Security configures sensible defaults automatically but allows fine-grained control. Always set Content-Security-Policy, HSTS, and X-Content-Type-Options in production.
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
        throws Exception {
    return http
        .headers(headers -> headers

            // ── Prevent clickjacking ───────────────────────────────
            .frameOptions(fo ->
                fo.deny())    // or sameOrigin() for iframe embeds

            // ── Prevent MIME-type sniffing ─────────────────────────
            .contentTypeOptions(Customizer.withDefaults())

            // ── HSTS — HTTPS-only for 1 year ──────────────────────
            .httpStrictTransportSecurity(hsts -> hsts
                .includeSubDomains(true)
                .preload(true)
                .maxAgeInSeconds(31536000))

            // ── Referrer-Policy ────────────────────────────────────
            .referrerPolicy(rp -> rp
                .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy
                    .STRICT_ORIGIN_WHEN_CROSS_ORIGIN))

            // ── Permissions-Policy ─────────────────────────────────
            .permissionsPolicy(pp -> pp
                .policy("camera=(), microphone=(), geolocation=()"))

            // ── Content-Security-Policy ────────────────────────────
            .contentSecurityPolicy(csp -> csp
                .policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self'; " +
                    "style-src 'self' 'unsafe-inline'; " +
                    "img-src 'self' data: https:; " +
                    "font-src 'self'; " +
                    "connect-src 'self'; " +
                    "frame-ancestors 'none'; " +
                    "base-uri 'self'; " +
                    "form-action 'self'"))
        )
        .build();
}

Multiple Filter Chains

Different parts of an application can use different security strategies. Use @Order to control chain precedence — lower order runs first. A request is processed by the first chain whose securityMatcher matches. Define specific matchers on each chain and let the last chain act as a catch-all.
Java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class MultiChainSecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;
    private final ApiKeyAuthFilter        apiKeyFilter;

    // ── Chain 1: Actuator — separate credentials ──────────────────────
    @Bean
    @Order(1)
    public SecurityFilterChain actuatorChain(HttpSecurity http)
            throws Exception {
        return http
            .securityMatcher("/actuator/**")
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health",
                                 "/actuator/info").permitAll()
                .requestMatchers("/actuator/**")
                    .hasRole("ACTUATOR"))
            .httpBasic(Customizer.withDefaults())
            .build();
    }

    // ── Chain 2: Public API — API key authentication ──────────────────
    @Bean
    @Order(2)
    public SecurityFilterChain publicApiChain(HttpSecurity http)
            throws Exception {
        return http
            .securityMatcher("/api/public/**")
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated())
            .addFilterBefore(apiKeyFilter,
                UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    // ── Chain 3: Private API — JWT authentication ─────────────────────
    @Bean
    @Order(3)
    public SecurityFilterChain privateApiChain(HttpSecurity http)
            throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter,
                UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    // ── Chain 4: Web — form login with session ────────────────────────
    @Bean
    @Order(4)
    public SecurityFilterChain webChain(HttpSecurity http)
            throws Exception {
        return http
            // No securityMatcher — matches everything not matched above
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/register",
                                 "/css/**", "/js/**").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard"))
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout"))
            .build();
    }
}

Actuator Security

Spring Boot Actuator endpoints expose operational data — health, metrics, environment, and beans. Restrict sensitive endpoints to authorised roles and expose only safe endpoints publicly. Never expose /actuator/env, /actuator/heapdump, or /actuator/shutdown to unauthenticated requests in production.
Java
# ── application.yml — expose only safe endpoints ─────────────────────
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
        # Never expose in production without auth:
        # env, configprops, heapdump, threaddump, shutdown, beans
  endpoint:
    health:
      show-details: when-authorized
      show-components: when-authorized
    shutdown:
      enabled: false    # never enable without strict security

  # Separate port — firewall can block it from public internet
  server:
    port: 8081

// ── Actuator security filter chain ────────────────────────────────────
@Bean
@Order(1)
public SecurityFilterChain actuatorFilterChain(HttpSecurity http)
        throws Exception {
    return http
        .securityMatcher(
            EndpointRequest.toAnyEndpoint())
        .csrf(AbstractHttpConfigurer::disable)
        .authorizeHttpRequests(auth -> auth
            // Fully public — no auth required
            .requestMatchers(
                EndpointRequest.to(
                    HealthEndpoint.class,
                    InfoEndpoint.class))
                .permitAll()
            // Metrics visible to monitoring role
            .requestMatchers(
                EndpointRequest.to(
                    MetricsEndpoint.class,
                    PrometheusEndpoint.class))
                .hasRole("MONITORING")
            // Everything else — admin only
            .anyRequest()
                .hasRole("ACTUATOR_ADMIN"))
        .httpBasic(Customizer.withDefaults())
        .build();
}

// ── Dedicated actuator credentials ────────────────────────────────────
@Bean
public UserDetailsService actuatorUserDetailsService(
        PasswordEncoder encoder) {
    UserDetails monitoring = User.withUsername("monitoring")
        .password(encoder.encode(
            "${MONITORING_PASSWORD}"))
        .roles("MONITORING")
        .build();

    UserDetails actuatorAdmin = User.withUsername("actuator-admin")
        .password(encoder.encode(
            "${ACTUATOR_ADMIN_PASSWORD}"))
        .roles("ACTUATOR_ADMIN", "MONITORING")
        .build();

    return new InMemoryUserDetailsManager(
        monitoring, actuatorAdmin);
}