How Spring Security Works
Follow a request through the servlet filters Spring Security adds, and see where authentication and authorisation decisions are actually made.
Request -> DelegatingFilterProxy -> FilterChainProxy
-> first matching SecurityFilterChain
-> CsrfFilter, authentication filters,
ExceptionTranslationFilter, AuthorizationFilter
-> DispatcherServlet -> @RestControllerAdd spring-boot-starter-security and every endpoint in the application suddenly answers 401 Unauthorized (a browser asking for HTML is redirected to a login page instead). Nothing in your controllers changed. Spring Security works in front of them, as servlet filters, and Spring Boot switches on a safe default the moment the starter is on the classpath.
The default Boot gives you
With no security configuration of your own, Boot registers one filter chain that requires authentication for every request and enables both form login and HTTP Basic. If you have not defined a UserDetailsService or similar bean, it also creates a single user called user with a random password printed in the startup log. That is fine for a first run and wrong for anything else: the password changes on every restart.
From servlet container to your controller
The container knows nothing about Spring beans, so Boot registers a DelegatingFilterProxy that hands each request to the FilterChainProxy bean. That bean holds one or more SecurityFilterChains. It picks the first chain whose matcher fits the request and runs only that chain's filters, in a fixed order. A few of them matter for every API:
SecurityContextHolderFilterloads any existing security context (from the session, if you use one).CorsFilterandCsrfFilterhandle cross-origin and forged requests.- Authentication filters such as
BasicAuthenticationFilterorBearerTokenAuthenticationFilterread credentials and try to authenticate. ExceptionTranslationFilterturns security exceptions into401or403responses.AuthorizationFilter, last, decides whether this caller may reach this URL.
Authentication and authorisation
Authentication answers who are you? An authentication filter builds an unauthenticated token and passes it to the AuthenticationManager (normally a ProviderManager), which asks each AuthenticationProvider in turn. For username and password that is a DaoAuthenticationProvider, which loads the user through your UserDetailsService and checks the password with your PasswordEncoder. On success the resulting Authentication goes into the SecurityContextHolder, a thread-local your code can read for the rest of the request.
Authorisation answers may you do this? It happens twice at most: once per URL in AuthorizationFilter, and again on annotated methods if you enable method security.
Consequences people miss
Because the filters run before the DispatcherServlet, a 401 from a missing token never reaches your @RestControllerAdvice. Customise it on the filter chain instead, with an entry point. A 403 means the caller was authenticated but denied; a 401 means Spring Security did not know who they were. Mixing these up in clients leads to login loops.
When a rule does not behave as expected, turn on TRACE logging for org.springframework.security. It logs the chosen chain, every filter it runs and the reason for each denial. Never leave it on in production: it is very verbose and can print request details.
Example
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
// Roughly what Spring Boot configures for you when you declare nothing.
// Declaring your own SecurityFilterChain bean replaces Boot's default chain.
public class DefaultLikeSecurityConfig {
SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
http
// AuthorizationFilter: every request needs an authenticated user
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// UsernamePasswordAuthenticationFilter plus a generated login page
.formLogin(Customizer.withDefaults())
// BasicAuthenticationFilter: reads the Authorization: Basic header
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
When to use it
- Explain to a team why adding the security starter turned every endpoint into a 401 and what replaced Boot's default.
- Trace a request that returns 403 in staging by reading the TRACE log of the filter chain that handled it.
- Feed login successes and failures into an audit log to spot password-guessing against an API.
More examples
Seeing the filter chain in the log
<code>TRACE</code> output is the quickest way to answer "why is this a 403?": it names the matcher that applied and the authorisation decision. The <code>spring.security.user.*</code> properties only shape Boot's default user, so they are a convenience for local runs and have no effect once you provide real users. Read the password from an environment variable even in development so it never ends up in Git.
# Logs the chain chosen for each request, every filter it runs
# and the reason a request was denied. Development only.
logging.level.org.springframework.security=TRACE
# A fixed development user instead of the generated password.
# Ignored as soon as you declare your own UserDetailsService.
spring.security.user.name=dev
spring.security.user.password=${DEV_PASSWORD}
spring.security.user.roles=USER,ADMIN
Listing the filters at startup
<code>FilterChainProxy</code> is an ordinary bean, so you can inject it and print what it holds. Running this once after changing your configuration shows the exact filters and their order, which settles arguments about whether CSRF or CORS is really active. It is limited to the <code>dev</code> profile because the output is noise in production.
import jakarta.servlet.Filter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.stereotype.Component;
@Component
@Profile("dev")
public class FilterChainReport implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(FilterChainReport.class);
private final FilterChainProxy filterChainProxy;
FilterChainReport(FilterChainProxy filterChainProxy) {
this.filterChainProxy = filterChainProxy;
}
@Override
public void run(ApplicationArguments args) {
int index = 0;
for (SecurityFilterChain chain : filterChainProxy.getFilterChains()) {
log.info("Security chain {}:", index++);
for (Filter filter : chain.getFilters()) {
log.info(" {}", filter.getClass().getSimpleName());
}
}
}
}
Recording failed logins
Spring Boot registers a <code>DefaultAuthenticationEventPublisher</code>, so the <code>ProviderManager</code> publishes an application event for every success and failure. Listening to them gives you an audit trail and a hook for counting failures per user, the basis for lockout or alerting. The failure event carries the exception, which tells you whether the user was unknown or the password was wrong; keep that distinction in logs, not in the response to the client.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;
@Component
public class AuthenticationAudit {
private static final Logger log = LoggerFactory.getLogger(AuthenticationAudit.class);
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
log.info("login ok user={}", event.getAuthentication().getName());
}
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
// Log the reason type, never the submitted password.
log.warn("login failed user={} reason={}",
event.getAuthentication().getName(),
event.getException().getClass().getSimpleName());
}
}
Discussion