Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Spring Security Architecture Deep Dive

How Spring Security actually works: the filter chain and its ordering, SecurityContextHolder, the AuthenticationManager delegation model, and where to plug in custom logic.

Beginner6 min readUpdated
On this page

Spring Security has a reputation for being impenetrable, which comes almost entirely from people configuring it without knowing what the filter chain is. Once you can name the filters and say what each one does, the configuration DSL becomes readable.

Key Takeaways

  • Spring Security is a chain of servlet filters, inserted into the container by one DelegatingFilterProxy.
  • Filters run before the DispatcherServlet, so security errors bypass @ControllerAdvice.
  • SecurityContextHolder is a ThreadLocal — it does not cross into async work by itself.
  • AuthenticationManager delegates to a list of providers; the first that supports the token handles it.
  • With several SecurityFilterChain beans, only the first matching chain runs.

The chain

One proxy delegates into an ordered chain of filters. Everything happens before the DispatcherServlet, which is why security errors need their own handlers.

The filters that matter most, in order:

FilterJob
CorsFilterApplies CORS policy; must run early so preflights are answered
CsrfFilterValidates the CSRF token on state-changing methods
LogoutFilterHandles the logout URL before anything tries to authenticate
UsernamePasswordAuthenticationFilterProcesses form logins
BearerTokenAuthenticationFilterExtracts and validates a JWT
ExceptionTranslationFilterCatches security exceptions and converts them to 401/403
AuthorizationFilterThe final gate — checks authorities against the rules

ExceptionTranslationFilter is the one worth understanding. It wraps everything downstream in a try/catch. An AuthenticationException means "we do not know who you are" and it invokes the AuthenticationEntryPoint — typically a 401 with a WWW-Authenticate header. An AccessDeniedException means "we know who you are and you may not do this" and it invokes the AccessDeniedHandler, typically a 403.

Configuration

SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
 
    // Most specific first. FilterChainProxy uses the FIRST matching chain, not
    // all of them, so an unordered catch-all would swallow everything.
    @Bean
    @Order(1)
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")
            // No cookies, no browser, no CSRF risk.
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/v1/public/**").permitAll()
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(problemDetailEntryPoint())
                .accessDeniedHandler(problemDetailAccessDeniedHandler()));
        return http.build();
    }
 
    @Bean
    @Order(2)
    SecurityFilterChain webChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/dashboard"))
            .logout(logout -> logout.logoutSuccessUrl("/").deleteCookies("JSESSIONID"))
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                    "default-src 'self'; frame-ancestors 'none'; base-uri 'self'")));
        return http.build();
    }
}

Splitting API and web concerns into separate chains is the pattern to reach for early. They want opposite settings — stateless versus session, CSRF off versus on, bearer token versus form login — and trying to express both in one chain produces configuration nobody can reason about.

Making security errors match your error contract

ProblemDetailEntryPoint.java
@Bean
AuthenticationEntryPoint problemDetailEntryPoint() {
    return (request, response, ex) -> {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/problem+json");
        response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer");
        response.getWriter().write("""
            {"type":"https://api.acme.com/errors/unauthenticated",
             "title":"Unauthenticated","status":401,
             "detail":"A valid bearer token is required."}
            """);
    };
}

Without this, an unauthenticated API call returns Spring's default HTML error page or an empty body, while every other error in your API returns RFC 7807 JSON. Clients then need two parsers.

The authentication model

ProviderManager tries each provider until one claims the token type. DaoAuthenticationProvider is the username/password path.

Four interfaces make up the model, and knowing which to implement saves a great deal of guessing.

Authentication is both the request ("here is a username and password") and the result ("this is who they are and what they may do"). The same object is passed in unauthenticated and returned authenticated, which is initially confusing and turns out to be convenient.

AuthenticationManager — practically always ProviderManager — holds a list of AuthenticationProviders and asks each whether it supports the token type. The first that does gets to authenticate.

UserDetailsService loads a user by name. It returns UserDetails, not an authentication decision; the provider compares the password.

GrantedAuthority is a permission string. The ROLE_ prefix is a convention hasRole("ADMIN") expands to ROLE_ADMIN, while hasAuthority uses the string verbatim — the single most common source of "why is my rule not matching".

CustomUserDetailsService.java
@Service
public class DatabaseUserDetailsService implements UserDetailsService {
 
    private final UserRepository users;
 
    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = users.findByEmailIgnoreCase(username)
                // Never distinguish "no such user" from "wrong password" in the
                // message that reaches the client — that is an enumeration oracle.
                .orElseThrow(() -> new UsernameNotFoundException("bad credentials"));
 
        return org.springframework.security.core.userdetails.User
                .withUsername(user.email())
                .password(user.passwordHash())
                .authorities(user.roles().stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                        .toList())
                .accountLocked(user.lockedUntil() != null && user.lockedUntil().isAfter(Instant.now()))
                .disabled(!user.enabled())
                .build();
    }
}

SecurityContextHolder and threads

The context lives in a ThreadLocal, which is fine for a request thread and a problem everywhere else:

AsyncSecurityConfig.java
@Bean
Executor securityAwareExecutor() {
    var executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setQueueCapacity(200);
    executor.initialize();
    // Copies the SecurityContext into the worker thread and clears it after.
    return new DelegatingSecurityContextExecutor(executor);
}

The alternative, MODE_INHERITABLETHREADLOCAL, propagates to threads created from the request thread — but pooled threads are created once at startup, so it does nothing for an executor. The delegating wrapper is the reliable option.

The same gap exists for message listeners and scheduled jobs. Neither has an authenticated user unless you set one explicitly, which is why a @PreAuthorize on a service method called from a Kafka listener will simply deny.

Method security

MethodSecurity.java
@Service
public class DocumentService {
 
    @PreAuthorize("hasRole('ADMIN') or #ownerId == authentication.name")
    public List<Document> listFor(String ownerId) { }
 
    @PostAuthorize("returnObject.ownerId == authentication.name")
    public Document find(String id) { }
 
    @PreAuthorize("hasAuthority('SCOPE_documents.write')")
    public Document create(DocumentPayload payload) { }
}

@PreAuthorize runs before the method and is what you want almost always. @PostAuthorize runs after and inspects the return value — useful when ownership is only knowable once loaded, but note that the method has already executed, so it must not have side effects.

Method security is proxy-based, which means it inherits the self-invocation limitation: calling a @PreAuthorize method from inside the same class bypasses the check entirely.

What to take away

Learn the filter order and the two exception paths, split API and web into separate chains, and remember that only the first matching chain runs. Implement UserDetailsService for your user store, never leak whether a username exists, and propagate the security context explicitly into async work.

Frequently Asked Questions

Why does my @ControllerAdvice not catch authentication errors?
Security filters run before the DispatcherServlet, so an exception thrown there never reaches Spring MVC exception handling. Configure an AuthenticationEntryPoint for 401s and an AccessDeniedHandler for 403s if you want those responses to match the rest of your error contract.
Why is SecurityContext empty in my @Async method?
SecurityContextHolder uses a ThreadLocal by default, so a different thread sees nothing. Set the strategy to MODE_INHERITABLETHREADLOCAL, or wrap your executor with DelegatingSecurityContextExecutor, which propagates the context explicitly and works with pooled threads.
How do multiple SecurityFilterChain beans work?
FilterChainProxy holds an ordered list and uses the FIRST chain whose securityMatcher matches the request — not all matching chains. Order them with @Order from most specific to most general, and put the catch-all last.

Related tutorials