Safeguard
Security Guides

Spring Security Configuration Guide: The Modern SecurityFilterChain Approach

A practical Spring Security configuration guide for 2026 using the component-based SecurityFilterChain, method security, CSRF, CORS, and password encoding.

Marcus Chen
AppSec Engineer
5 min read

Spring Security is powerful and, historically, confusing — largely because so much online guidance still shows the deprecated WebSecurityConfigurerAdapter pattern that was removed in Spring Security 6. If you're copying a five-year-old Stack Overflow answer into a new project, you're not just fighting compiler errors; you may be reintroducing insecure defaults the framework has since fixed. This guide covers the current, component-based configuration model and the four decisions every application has to get right: what's authenticated, how authorization is enforced, how CSRF and CORS are handled, and how credentials are stored.

What replaced WebSecurityConfigurerAdapter?

The modern approach registers a SecurityFilterChain bean instead of extending an adapter class. This is more than a syntax change — it makes security configuration a first-class, composable bean you can test and inspect, and it removes the confusing inheritance that led to misconfigured overrides. A minimal, secure starting point looks like this:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())   // secure-by-default
            .formLogin(Customizer.withDefaults())
            .logout(Customizer.withDefaults());
        return http.build();
    }
}

The load-bearing line is .anyRequest().authenticated(). It ensures any endpoint you forget to configure is denied by default rather than left open — the opposite of the fail-open mistakes that plague hand-rolled auth.

Enforce authorization close to the code

URL-based rules are useful, but they drift from reality as controllers evolve. Method-level security binds authorization to the method that does the work, which is far harder to bypass through a new route or refactor:

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

@Service
public class InvoiceService {

    @PreAuthorize("hasRole('FINANCE') and #invoice.ownerId == authentication.name")
    public void approve(Invoice invoice) { /* ... */ }
}

Combining a role check with an ownership check (#invoice.ownerId == authentication.name) closes the most common access-control gap: an authenticated user reaching another user's data. Broken access control is OWASP's A01 for 2021, and expression-based method security is your most direct defense.

Handle CSRF and CORS deliberately

CSRF protection is enabled by default for browser-based sessions, and you should leave it on for any app using cookies. The common mistake is disabling it wholesale to "make the API work." If your API is stateless and token-authenticated (for example, bearer JWTs, which aren't automatically attached by browsers), CSRF protection can be disabled for those routes — but do it narrowly and knowingly:

http
    .csrf(csrf -> csrf
        .ignoringRequestMatchers("/api/**"))   // stateless token API only
    .cors(Customizer.withDefaults());          // wire a CorsConfigurationSource bean

For CORS, never reflect arbitrary origins. Define an explicit allow-list of trusted origins; a wildcard combined with credentials is both insecure and rejected by browsers.

Store credentials correctly

Never store passwords with a bare hash. Use an adaptive, salted encoder — BCrypt is the sensible default, and Spring Security's DelegatingPasswordEncoder lets you migrate algorithms over time by prefixing stored hashes with their scheme:

@Bean
PasswordEncoder passwordEncoder() {
    // {bcrypt}, {argon2}, etc. — supports rolling upgrades of stored hashes
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

Because the encoder tags each stored hash with its algorithm, you can introduce Argon2 for new passwords while old BCrypt hashes keep verifying — no forced reset for existing users.

Manage sessions defensively

For cookie-based applications, session fixation is a real and often-overlooked risk: if the session identifier doesn't change when a user authenticates, an attacker who planted a known session ID can ride the authenticated session. Spring Security migrates the session on login by default, but assert the behavior and cap concurrent sessions so a stolen cookie can't be used indefinitely alongside the legitimate user:

http.sessionManagement(session -> session
    .sessionFixation(fixation -> fixation.migrateSession())
    .maximumSessions(1)
    .maxSessionsPreventsLogin(false));   // newest login wins, old session invalidated

For stateless token APIs, go the other way and disable server-side sessions entirely with SessionCreationPolicy.STATELESS, so the server never issues a JSESSIONID it would otherwise have to defend. Whichever model you choose, be deliberate — the dangerous state is the accidental middle ground where you're carrying session cookies you never meant to rely on.

Test your configuration, don't just write it

Security configuration is code, and untested code drifts. Spring Security's test support lets you assert authorization behavior in the same suite as your business logic, so a well-meaning refactor that opens an endpoint fails a test instead of shipping. Use spring-security-test to exercise both the allowed and the denied paths:

@Test
@WithMockUser(roles = "USER")
void nonAdminIsForbiddenFromAdminArea() throws Exception {
    mockMvc.perform(get("/admin/reports"))
        .andExpect(status().isForbidden());
}

The negative test — confirming a non-admin is rejected — is the one that actually protects you, and it's the one teams most often skip. Assert denials explicitly for every privileged route.

Configuration checklist

  • Use a SecurityFilterChain bean; delete any WebSecurityConfigurerAdapter
  • End authorization rules with .anyRequest().authenticated()
  • Add @EnableMethodSecurity with @PreAuthorize including ownership checks
  • Keep CSRF on for cookie sessions; disable only for stateless token routes
  • Define an explicit CORS origin allow-list — never a credentialed wildcard
  • Use DelegatingPasswordEncoder (BCrypt/Argon2), never plain hashes

How Safeguard helps

Configuration mistakes are only half the Spring Security story — the framework itself, and the servlet container beneath it, ship a steady stream of CVEs. Safeguard's software composition analysis tracks the exact Spring Security, Spring Framework, and Tomcat versions on your classpath (including transitive ones you never declared) and flags known authentication-bypass and request-handling vulnerabilities as they're disclosed. Its reachability engine then prioritizes the findings that touch code paths you actually expose, so a framework CVE in a feature you don't use doesn't derail a sprint, while an exploitable one gets escalated. When a patched release is available, auto-fix pull requests prepare the upgrade for you. Teams evaluating SAST-heavy incumbents often compare the reachability model against the Safeguard vs Checkmarx comparison before deciding.

Get started free at app.safeguard.sh/register, and browse the framework-scanning documentation at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.