Spring Security is implemented as a chain of servlet Filters, registered as a single entry point (DelegatingFilterProxy, which delegates to a Spring-managed FilterChainProxy) in front of the rest of the application — meaning security logic runs before a request ever reaches Spring MVC's DispatcherServlet.
A SecurityFilterChain is an ordered list of individual filters, each responsible for one specific concern, roughly in this order for a typical configuration:
SecurityContextPersistenceFilter/SecurityContextHolderFilter— loads any existing security context (e.g., from a session) for the request.CorsFilter— handles CORS preflight/actual request headers, if configured.CsrfFilter— validates a CSRF token for state-changing requests, if CSRF protection is enabled.- Authentication filters — e.g.,
UsernamePasswordAuthenticationFilter(form login), or a custom JWT-parsing filter — attempt to authenticate the request and populate theSecurityContextwith anAuthenticationobject if successful. ExceptionTranslationFilter— catches authentication/authorization exceptions thrown further down the chain and converts them into an appropriate HTTP response (401 for unauthenticated, 403 for unauthorized) or redirect (e.g., to a login page).FilterSecurityInterceptor/AuthorizationFilter— makes the final authorization decision for the specific request, based on configured access rules, before allowing it to proceed to the actual application.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
Only once a request has passed every applicable filter in the chain does it actually reach Spring MVC's DispatcherServlet and, eventually, your controller method — if any filter determines the request should be rejected (missing/invalid credentials, insufficient authority), it short-circuits the chain and returns an error response directly, without the controller ever being invoked.
You can register multiple SecurityFilterChain beans, each matched to a different RequestMatcher (e.g., one chain for /api/** using stateless JWT auth, a separate chain for the rest of the app using session-based form login) — a common pattern for applications that need to support more than one authentication style simultaneously.
Related Resources
These two terms are often used loosely together, but Spring Security (like security generally) treats them as distinct, sequential steps:
Authentication answers "who are you?" — verifying a claimed identity, typically by checking supplied credentials (a username/password pair, a bearer token, a client certificate) against some trusted source (a database, an identity provider). On success, Spring Security populates a SecurityContext (accessible via SecurityContextHolder.getContext()) with an Authentication object representing the now-verified principal and their granted authorities.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
auth.getName(); // "alice" — who they are
auth.getAuthorities(); // [ROLE_USER, ROLE_ADMIN] — what they're granted, established during authentication
Authorization answers "are you allowed to do this?" — given an already authenticated principal, checking whether their granted authorities/roles satisfy whatever a specific resource or action requires:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN") // authorization rule
.requestMatchers("/api/**").authenticated() // just requires *some* valid identity
.anyRequest().permitAll());
@PreAuthorize("hasRole('ADMIN')") // method-level authorization
void deleteUser(Long id) { ... }
Sequencing matters: authorization checks only make sense after authentication has already established who the request is from — an unauthenticated request typically fails with 401 Unauthorized (authentication problem) before authorization is even evaluated, whereas an authenticated-but-insufficiently-privileged request fails with 403 Forbidden (authorization problem) once its identity is known but found lacking the required permission.
A useful mnemonic: authentication is about identity ("prove who you are"); authorization is about permission ("given who you are, what are you allowed to do").
Older style (deprecated since Spring Security 5.7, removed in 6.x): security configuration was defined by extending WebSecurityConfigurerAdapter and overriding its configure(HttpSecurity http) method:
@Configuration
@EnableWebSecurity
class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and().formLogin();
}
}
This relied on class inheritance — a real limitation, since Java's single inheritance meant a configuration class could only extend one adapter, making it awkward to compose or define multiple independent security configurations cleanly.
Modern style (component-based, since Spring Security 5.7+): you declare a SecurityFilterChain as a plain @Bean, built via a fluent, largely lambda-based DSL applied to an injected HttpSecurity instance:
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.csrf(Customizer.withDefaults());
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Advantages of the newer approach:
- No inheritance constraint — configuration is just a
@Beanmethod, composable like any other Spring configuration. - Multiple independent
SecurityFilterChainbeans are straightforward to define, each matched to a different request pattern viasecurityMatcher(...), letting different parts of an application (e.g., a stateless JWT-secured/api/**versus a session-based form-login admin console) coexist cleanly with entirely separate security rules. - More consistent, lambda-DSL style aligned with how the rest of modern Spring configuration (
WebMvcConfigurer, etc.) is typically written.
Anyone maintaining an older codebase should expect to see WebSecurityConfigurerAdapter-based configuration, but new code should always use the SecurityFilterChain @Bean approach — the adapter class is fully removed in current Spring Security major versions.
URL-pattern-based authorization (.requestMatchers("/admin/**").hasRole("ADMIN")) works well for broad, path-based rules, but many real authorization decisions are more naturally expressed on the method itself — especially in a service layer that might be called from multiple different controllers, or where the decision depends on the specific arguments/data involved.
Enable it on a configuration class:
@Configuration
@EnableMethodSecurity // (replaces the older @EnableGlobalMethodSecurity)
class MethodSecurityConfig { }
@PreAuthorize evaluates a SpEL expression before the method runs — if it evaluates to false, the method body never executes at all, and Spring throws AccessDeniedException:
@PreAuthorize("hasRole('ADMIN')")
void deleteUser(Long userId) { ... }
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
User getUserProfile(Long userId) { ... } // users can view their own profile, admins can view anyone's
@PostAuthorize evaluates after the method has already run, with access to the method's return value via the returnObject SpEL variable — useful when the authorization decision genuinely depends on the fetched data itself, not just the input arguments:
@PostAuthorize("returnObject.owner == authentication.name")
Document getDocument(Long id) {
return documentRepository.findById(id).orElseThrow();
// if the returned Document doesn't belong to the caller, access is denied *after* the fetch
}
Practical note on @PostAuthorize: because the method body already ran (including, potentially, a database read) before the check happens, it's less efficient than a @PreAuthorize check that could reject the call upfront — but it's the only option when the authorization rule can't be evaluated without first knowing what the method would actually return.
Both annotations rely on the same AOP proxy mechanism as @Transactional — meaning the same self-invocation caveat applies: calling a @PreAuthorize-annotated method on this from within the same class bypasses the proxy, and the security check silently never runs.
Storing passwords in plaintext means that any database breach (or even an overly-permissive internal query) directly exposes every user's actual password — a catastrophic outcome, especially since many users reuse passwords across services. Even naive hashing with a fast, general-purpose algorithm (plain MD5, SHA-1, or unsalted SHA-256) is inadequate: those algorithms are designed to be fast, which is exactly the wrong property for password storage — it makes brute-force/dictionary attacks against leaked hashes cheap, and without a per-password salt, identical passwords produce identical hashes, letting attackers use precomputed rainbow tables to reverse them in bulk.
Spring Security's PasswordEncoder interface abstracts password hashing so the application never handles or compares raw passwords directly:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// registering a user
String hashed = passwordEncoder.encode(rawPassword); // store this, never the raw password
// checking a login attempt
boolean matches = passwordEncoder.matches(rawPasswordAttempt, hashed); // never compare hashes with ==
BCrypt, the long-standing recommended default, addresses both weaknesses of naive hashing:
- Built-in random salt: each call to
encode()generates a fresh random salt automatically and embeds it directly in the resulting hash string — so even two users with the identical password get completely different stored hash values, defeating rainbow-table attacks entirely. - Deliberately slow, and tunable ("adaptive"): BCrypt includes a configurable work factor (a "cost" parameter, typically defaulting to 10) controlling how computationally expensive each hash operation is. This is a deliberate, and very important, design choice: making each individual guess expensive is exactly what makes brute-forcing a leaked hash impractical at scale, even though it costs a small, one-time, imperceptible delay for a legitimate login. As hardware gets faster over time, the work factor can simply be increased for newly-hashed passwords, without needing to migrate every existing stored hash.
In practice: matches() re-derives the hash using the salt embedded in the stored value and compares the result — this is why you never need to store the salt separately, and why comparing two BCrypt hash strings directly with .equals() would be wrong; only passwordEncoder.matches(raw, stored) performs the comparison correctly.