Spring Security architecture explained
The previous doc covered what Spring Security handles (verification) and what it does not (user storage, UI). Now let's open the hood and see how the machinery actually works.
This document is heavily inspired by the Spring Security Architecture Explained video by Amigoscode. If you learn better from video, go watch it — the diagrams are excellent.
Everything is a filter
The cross-cutting concerns document covers servlet filters and how they intercept requests before they reach your controller.
When a client sends an HTTP request to your API, it does not reach the controller directly. The request passes through a chain of servlet filters, and each filter performs a security check based on your configuration.
If any filter rejects the request (missing token, wrong role, expired session), the chain bails out early and returns a 401 (unauthorized) or 403 (forbidden). The controller never sees it.
Spring Security uses DelegatingFilterProxy to bridge the Servlet container and the Spring context. The Servlet container knows nothing about Spring beans; DelegatingFilterProxy delegates to a Spring-managed filter that does the real work.
SecurityFilterChain
Not every request should go through the same filters. API calls using JWT do not need session management, and web routes using form login do not need CSRF disabled. Spring Security solves this with SecurityFilterChain: you can define multiple chains, each matched to a URL pattern.
In practice, you configure this with the SecurityFilterChain bean:
Each SecurityFilterChain bean defines which filters run for which URLs. Filters execute in order, and the addFilterBefore / addFilterAfter methods let you control where your custom filter sits in that order.
Authentication flow
Now let's zoom into what happens when a filter needs to authenticate a request. The JWT authentication filter is a good example: it extracts the token from the Authorization header, creates an Authentication object, and passes it down the chain.
Here is what each piece does:
- JWT Auth Filter intercepts the request, extracts the Bearer token, and creates a
UsernamePasswordAuthenticationTokenwith the credentials. - AuthenticationManager is the interface with a single method:
authenticate(). It delegates to the next layer. - ProviderManager is the most common implementation. It iterates through a list of
AuthenticationProviders until one can handle the token type. - DaoAuthenticationProvider handles username/password authentication. It needs two collaborators:
- UserDetailsService loads the user from the database and returns a
UserDetailsobject. - PasswordEncoder compares the raw password from the request against the hashed password from the database.
- UserDetailsService loads the user from the database and returns a
- On success, the provider returns a fully populated
Authenticationobject. On failure, it throws anAuthenticationException.
Here is the custom JWT filter in simplified form:
Notice the last two lines. After parsing the token and building the Authentication object, the filter stores it in SecurityContextHolder and then calls the next filter. That is the whole purpose of the filter: read the token, authenticate, store the result, and move on.
If the token is missing, the filter simply calls filterChain.doFilter(request, response) without setting the context. Down the line, Spring Security will see that no authentication exists and return a 401 or 403.
UserDetails
The UserDetails interface is what Spring Security uses to represent a loaded user. Your UserDetailsService returns it, and downstream filters and authorization checks read from it.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities(); // roles & permissions
String getPassword(); // hashed password
String getUsername(); // unique identifier
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
The getAuthorities() method returns the roles and permissions for the user. You can guard an endpoint so only admins reach it, or give an "admin trainee" a subset of admin permissions. The isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired, and isEnabled booleans let you control things like forced password rotation and soft-disabling users without deleting them.
The password must never be stored in plain text. Always hash it with a PasswordEncoder like BCryptPasswordEncoder.
SecurityContext
Once authentication succeeds, Spring Security needs somewhere to store the result for the rest of the request. That somewhere is SecurityContextHolder → SecurityContext → Authentication.
// On successful authentication:
SecurityContextHolder.getContext().setAuthentication(authentication);
// To read the current user anywhere in the request:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
SecurityContextHolder uses ThreadLocal under the hood, so the authenticated identity is available to any code running on the same request thread without passing it around explicitly. When the request ends, the context is cleared.
If authentication fails, the filter clears the context:
SecurityContextHolder.clearContext();
Putting it all together
Here is the full lifecycle of an authenticated request, from the moment it hits the server to the moment the controller processes it:
And here is how all the pieces wire together in a SecurityConfig class:
A request comes in, passes through the filter chain, gets authenticated by the ProviderManager and DaoAuthenticationProvider, and the result is stored in SecurityContextHolder. When the request reaches the controller, Spring Security already knows who the user is and whether they are allowed to be there. That is the entire architecture: filters, authentication, context, and authorization, wired together in one config class.