Spring Security Architecture Explained
Spring Security is the standard framework for securing Spring-based Java applications. For every request, it answers two questions: who are you? (authentication) and what are you allowed to do? (authorization).
In this tutorial, you’ll learn the Spring Security architecture, its core components, and how an HTTP request flows through them. Examples use Spring Boot 4 and Spring Security 7.
1. How a request flows through Spring Security
Every HTTP request passes through Spring Security before it reaches your controller. If any security check fails, the request is stopped and never reaches your code.
- The authentication filter asks the
AuthenticationManagerto check the credentials.- Form and Basic logins go to
DaoAuthenticationProvider, which usesUserDetailsServiceandPasswordEncoder. - Bearer JWTs go to
JwtAuthenticationProvider, which usesJwtDecoder.
- Form and Basic logins go to
- On success, the filter puts the result in the
SecurityContextHolder. AuthorizationFilterlater reads it from there to decide whether the request may continue.
Read the diagram from top to bottom. The request enters through DelegatingFilterProxy, FilterChainProxy picks a SecurityFilterChain, and its filters run one by one based on the order. The authentication filter sends credentials to the components on the right and saves the result in the SecurityContextHolder. Only after AuthorizationFilter approves does the request reach your controller. The rest of this tutorial explains each box.
2. How Spring Security uses servlet filters
Spring Security is built on servlet filters. A filter is a piece of code that runs before a request reaches your controller, and again as the response goes back. Each filter can inspect the request, change it, or stop it completely.
Think of filters as security checkpoints at an airport. Each checkpoint has one job, and a passenger only reaches the gate after passing all of them.
Three components connect Spring Security to this filter system.
DelegatingFilterProxy
The servlet container (such as Tomcat) manages filters, but it knows nothing about Spring beans. DelegatingFilterProxy is a bridge between the two. Tomcat sees it as a normal filter, and it simply passes the request to a Spring bean that does the real work.
Spring Boot registers this bridge for you automatically.
FilterChainProxy
The bean that DelegatingFilterProxy hands the request to is FilterChainProxy. It is the main entry point of Spring Security. Its job is to choose which security filter chain should handle the current request, then run that chain.
Because every request passes through FilterChainProxy, it is the best place to set a breakpoint when you want to debug a security problem.
SecurityFilterChain
A SecurityFilterChain is an ordered list of security filters, together with a rule that says which requests it applies to. You define it as a bean in your configuration.
An application can have several chains. For example, one chain can protect /api/** with JWT tokens, while another protects the rest of the site with a login form. FilterChainProxy uses the first chain whose rule matches the request, so the order of your chains matters.
3. Important built-in security filters
A typical security filter chain contains a dozen or more filters, and their order matters. For example, the filter that reads your login details must run before the filter that checks your permissions. Spring Security arranges them in the correct order for you.
The table lists the filters you will meet most often, in the order they run.
| Filter | What it does |
|---|---|
SecurityContextHolderFilter | Loads any existing security information (for example, from the HTTP session) at the start of the request. |
CsrfFilter | Protects against cross-site request forgery by checking a CSRF token on requests that change data. |
LogoutFilter | Handles logout requests and clears the user's security information. |
UsernamePasswordAuthenticationFilter | Reads the username and password submitted from a login form and starts authentication. |
BasicAuthenticationFilter | Reads credentials from an HTTP Basic Authorization header. |
BearerTokenAuthenticationFilter | Reads a bearer token, such as a JWT, from the Authorization header. |
AnonymousAuthenticationFilter | Marks the request as anonymous if nobody has logged in yet. |
ExceptionTranslationFilter | Turns security exceptions into the right HTTP response, such as a login redirect, 401, or 403. |
AuthorizationFilter | Makes the final decision on whether the request may reach the controller. |
You only get the filters for the features you turn on. If you never call formLogin(), for example, UsernamePasswordAuthenticationFilter is not added.
Tip: to see the exact filters in your own application, set logging.level.org.springframework.security=DEBUG in application.properties. Spring Security prints the full chain at startup.
4. Authentication architecture
Authentication is the process of proving who a user is. In Spring Security, a filter collects the user's credentials, and a small team of components checks them. Each component has one clear job.
Authentication
The Authentication object represents a user, and it is used in two ways:
- Before login, it carries the credentials the user sent, such as a username and password. At this point it is marked as not authenticated.
- After login, it carries the verified user: their identity (the principal) and their permissions (the authorities, such as
ROLE_ADMIN). Now it is marked as authenticated.
The most common implementation is UsernamePasswordAuthenticationToken.
AuthenticationManager
AuthenticationManager is the single entry point for checking credentials. It has just one method:
Authentication authenticate(Authentication authentication) throws AuthenticationException;
It either returns a fully authenticated Authentication object, or throws an AuthenticationException if the credentials are wrong.
ProviderManager
ProviderManager is the standard implementation of AuthenticationManager. It does not check credentials itself. Instead, it holds a list of AuthenticationProvider objects and asks each one in turn whether it can handle this type of login.
This design lets one application support several login methods at the same time, such as a database login and an LDAP login.
AuthenticationProvider
An AuthenticationProvider contains the actual logic for one type of authentication. The most common one is DaoAuthenticationProvider, which checks a username and password against your own user store. It relies on two helpers.
UserDetailsService
UserDetailsService loads a user by username. You implement it to read users from your own database:
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public AppUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) {
AppUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return User.withUsername(user.getUsername())
.password(user.getPasswordHash())
.roles(user.getRole())
.build();
}
}
It returns a UserDetails object, which holds the username, the stored password hash, the user's roles, and flags such as whether the account is locked.
PasswordEncoder
PasswordEncoder compares the password the user typed with the hash stored in the database. Passwords must never be stored as plain text. BCryptPasswordEncoder is the usual choice:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
How they work together
When a user submits a login form, these steps happen in order:
UsernamePasswordAuthenticationFilterreads the username and password and wraps them in an unauthenticated Authentication object.- The filter passes it to the
AuthenticationManager(aProviderManager). ProviderManagerfinds a provider that supports it, usuallyDaoAuthenticationProvider.- The provider calls
UserDetailsServiceto load the user from the database. - The provider uses
PasswordEncoderto check the submitted password against the stored hash. - If everything matches, the provider returns an authenticated Authentication object containing the user's roles.
- The filter stores that object in the
SecurityContext, which is covered next. If anything fails, anAuthenticationExceptionis thrown instead.
SecurityContext and sets it on the SecurityContextHolder.5. SecurityContext and SecurityContextHolder
After a user logs in, Spring Security needs somewhere to keep that information for the rest of the request. That place is the SecurityContext.
SecurityContextis a small container that holds the current user's Authentication object.SecurityContextHoldergives you access to that container from anywhere in your code.
SecurityContextHolder keeps the context in a ThreadLocal, so two requests running at the same time never see each other's user. FilterChainProxy clears the context when the request ends.You can think of the SecurityContextHolder as a visitor badge clipped to the request. Any code handling the request can look at the badge to see who the visitor is and what rooms they may enter.
Here is how to read the current user:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> roles = auth.getAuthorities();
In a controller, there is an easier way. Spring can inject the current user directly:
@GetMapping("/profile")
public String profile(@AuthenticationPrincipal UserDetails user) {
return "Hello, " + user.getUsername();
}
How it is stored
By default, SecurityContextHolder uses a ThreadLocal, so each request thread has its own separate security context. One user's information can never leak into another user's request.
At the end of the request, Spring Security clears the context. What happens between requests depends on your application:
- In a traditional web application with a login form, the context is saved in the HTTP session, so the user stays logged in on the next request.
- In a stateless REST API using JWT, nothing is saved. The token is checked again on every request, and a fresh context is built each time.
Note: because the context is tied to a thread, it is not automatically available inside code that runs on a different thread, such as an @Async method. Spring Security provides helpers like DelegatingSecurityContextExecutor for those cases.
6. Authorization architecture
Once Spring Security knows who the user is, authorization decides what that user may do. Spring Security can check permissions at two levels: on the URL of a request, and on individual methods in your code.
Authorities and roles
Every permission is represented as a GrantedAuthority, which is just a string. A role is an authority with the prefix ROLE_. So hasRole("ADMIN") checks for the authority ROLE_ADMIN, while hasAuthority("orders:write") checks for that exact string.
Roles usually describe who someone is (admin, customer). Authorities can describe what they can do (read orders, delete users) for finer control.
URL-based authorization
AuthorizationFilter sits near the end of the filter chain. It reads the user from the SecurityContext and asks an AuthorizationManager whether the request is allowed. If the answer is no, it throws an AccessDeniedException.
You define the rules in your SecurityFilterChain:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/orders/**").hasAuthority("orders:delete")
.anyRequest().authenticated());
Rules are checked from top to bottom, and the first rule that matches wins. Always put specific rules first and the general anyRequest() rule last.
AuthorizationManager
AuthorizationManager is the component that makes each access decision. Given the current user and the thing being protected, it returns granted or denied. Spring Security 6 uses it for both URL and method security, replacing the older AccessDecisionManager and voters.
Method-based authorization
URL rules are not always enough. Sometimes a check depends on the data, such as "users may only view their own orders." For that, enable method security and annotate your service methods:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig { }
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(Long id) { ... }
@PreAuthorize("#username == authentication.name")
public List<Order> findOrdersFor(String username) { ... }
}
@PreAuthorize checks the rule before the method runs. @PostAuthorize checks it after, which is useful when the decision depends on the returned value.
A good approach is to use URL rules for broad protection and method security for fine-grained, business-specific checks.
7. Exception handling
When security checks fail, Spring Security does not show a generic error page. ExceptionTranslationFilter catches security exceptions thrown by the filters after it and turns them into a proper HTTP response.
It handles two kinds of problems differently:
| Situation | Exception | Handled by | Typical response |
|---|---|---|---|
| The user is not logged in | AuthenticationException | AuthenticationEntryPoint | Redirect to the login page, or 401 Unauthorized for an API |
| The user is logged in but lacks permission | AccessDeniedException | AccessDeniedHandler | 403 Forbidden |
ExceptionTranslationFilter decides based on whether the user is logged in, not only on which exception was thrown. A failed form login never reaches it: UsernamePasswordAuthenticationFilter handles that with its AuthenticationFailureHandler.There is one special case. If an anonymous user hits a protected page, Spring Security treats it as "not logged in" and calls the AuthenticationEntryPoint, so they are asked to log in rather than shown a 403.
Custom JSON errors for a REST API
For APIs, you usually want JSON responses instead of redirects or HTML pages. You can plug in your own handlers:
http.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Authentication required\"}");
})
.accessDeniedHandler((request, response, deniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Access denied\"}");
}));
Remember: 401 means "we don't know who you are," and 403 means "we know who you are, but you can't do this." Getting these right makes your API much easier for clients to use.
8. Putting it all together: a complete configuration
The example below brings every part of the architecture into one configuration class for a Spring Boot 3 application. It uses a database login with a form, role-based URL rules, method security, and a password encoder.
First, add the starter to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Then create the configuration:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
// 1. The SecurityFilterChain: which filters run and which rules apply
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/register", "/css/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll())
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}
// 2. The PasswordEncoder used by DaoAuthenticationProvider
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Notice what is not in this class. You don't create an AuthenticationManager, a ProviderManager, or a DaoAuthenticationProvider. Spring Boot builds them automatically as soon as it finds your UserDetailsService bean (like AppUserDetailsService shown earlier) and your PasswordEncoder bean.
Here is how each line maps to the architecture:
| Configuration | Architecture component |
|---|---|
SecurityFilterChain bean | The chain that FilterChainProxy runs for every request |
| .formLogin(...) | Adds UsernamePasswordAuthenticationFilter |
| .authorizeHttpRequests(...) | Configures AuthorizationFilter and its AuthorizationManager |
| .logout(...) | Configures LogoutFilter |
@EnableMethodSecurity | Turns on @PreAuthorize and @PostAuthorize |
PasswordEncoder bean | Used by DaoAuthenticationProvider to check passwords |
UserDetailsService bean | Used by DaoAuthenticationProvider to load users |
Note: the older style of extending WebSecurityConfigurerAdapter was removed in Spring Security 6. If you see it in an older tutorial, replace it with a SecurityFilterChain bean as shown above.
9. Summary
Spring Security's architecture is a pipeline of small parts, each with one job. Every request enters through DelegatingFilterProxy, which hands it to FilterChainProxy. That picks the matching SecurityFilterChain, and its filters run in a fixed order.
An authentication filter collects credentials and passes them to the AuthenticationManager, which uses providers, a UserDetailsService, and a PasswordEncoder to verify them. The verified user is stored in the SecurityContext. Then AuthorizationFilter and method security decide what the user may do, while ExceptionTranslationFilter turns any failure into a clear 401 or 403 response.
When something goes wrong, follow the same path a request takes: which chain matched, which filter handled the login, what ended up in the SecurityContext, and which rule denied access. With that map in mind, even complex setups such as JWT, OAuth2, or multiple login methods become much easier to understand.
Frequently Asked Questions (FAQ)
Q1. What is the role of DelegatingFilterProxy and FilterChainProxy?
DelegatingFilterProxy is a standard servlet filter registered with the servlet container. It does no security work itself; it simply hands each request to a Spring bean named springSecurityFilterChain.
That bean is the FilterChainProxy. It picks the first SecurityFilterChain whose request matcher fits the request and runs that chain's security filters in order. This design lets Spring manage all security filters as normal beans.
Q2. Can an application have more than one SecurityFilterChain?
Yes. Define several SecurityFilterChain beans, give each one a securityMatcher such as /api/** or /admin/**, and order them with @Order. FilterChainProxy uses the first chain that matches, so put the most specific matchers first and a catch-all chain last.
A common setup is a stateless, token-based chain for /api/** and a session-based chain with a login form for the web pages.
Q3. What replaced WebSecurityConfigurerAdapter?
WebSecurityConfigurerAdapter was deprecated in Spring Security 5.7 and removed in 6.0. Configuration is now component-based: declare a @Bean method that takes HttpSecurity and returns a SecurityFilterChain.
Other pieces, such as UserDetailsService, PasswordEncoder and WebSecurityCustomizer, are also declared as ordinary beans instead of overridden methods.
Q4. How does the AuthenticationManager work?
AuthenticationManager has a single method, authenticate(). Its usual implementation, ProviderManager, keeps a list of AuthenticationProviders and asks each one whether it supports the type of Authentication it received.
The first provider that supports it attempts authentication and either returns a fully authenticated object or throws an AuthenticationException. If none succeeds, ProviderManager can delegate to a parent manager.
Q5. What is the difference between AuthenticationManager and AuthenticationProvider?
The AuthenticationManager is the entry point that filters call; it coordinates authentication. An AuthenticationProvider knows how to verify one particular kind of credential, such as a username and password (DaoAuthenticationProvider), a JWT or an LDAP login.
To support a new login method, you usually add a new provider rather than replacing the manager.
Q6. What does UserDetailsService do?
UserDetailsService loads a user by username and returns a UserDetails object containing the stored password hash, authorities and account status flags. DaoAuthenticationProvider calls it during login and then uses the PasswordEncoder to compare the submitted password with the stored hash.
Implement it to load users from your own database, or use the provided in-memory and JDBC implementations.
Q7. Which PasswordEncoder should I use?
Use PasswordEncoderFactories.createDelegatingPasswordEncoder(). It currently hashes new passwords with BCrypt and stores the algorithm as a prefix, such as {bcrypt}, so you can move to a stronger algorithm later without breaking existing passwords. Argon2 and SCrypt are also supported.
Never store plain-text passwords, and avoid NoOpPasswordEncoder outside throwaway demos.
Q8. What is the SecurityContextHolder and how does it store data?
SecurityContextHolder holds the SecurityContext, which contains the current Authentication. By default it uses a ThreadLocal, so the authenticated user is available anywhere in the code handling the current request on that thread.
Spring Security clears it after each request so that a pooled thread never carries one user's identity into another user's request.
Q9. How do I get the currently logged-in user?
In a controller, the simplest way is a method parameter annotated with @AuthenticationPrincipal, which injects the principal (for example your UserDetails or a Jwt). You can also accept an Authentication or Principal parameter.
Anywhere else, call SecurityContextHolder.getContext().getAuthentication(), but prefer passing user information explicitly into business code so it stays easy to test.
Q10. Why is the SecurityContext empty in @Async methods?
Because the context lives in a ThreadLocal, a task running on a different thread does not see it. Fix this by wrapping the executor with DelegatingSecurityContextAsyncTaskExecutor (or another DelegatingSecurityContext* wrapper), which copies the context to the worker thread for each task.
Setting the holder strategy to MODE_INHERITABLETHREADLOCAL also works for newly created threads, but it is unreliable with thread pools.
Q11. How does authorization work in Spring Security 6?
For web requests, the AuthorizationFilter near the end of the chain asks an AuthorizationManager whether the current Authentication may access the request, based on the rules you set with authorizeHttpRequests(). For methods, @EnableMethodSecurity applies the same model to annotations such as @PreAuthorize.
This replaced the older FilterSecurityInterceptor, AccessDecisionManager and voter model.
Q12. What is the difference between a role and an authority?
Both are GrantedAuthority strings. A role is simply an authority with the ROLE_ prefix by convention. hasRole("ADMIN") checks for ROLE_ADMIN, while hasAuthority("orders:read") checks the exact string.
Use roles for broad groups of users and fine-grained authorities, such as scopes or permissions, for individual actions.
Q13. How should URL rules and method security be combined?
Use URL rules in authorizeHttpRequests() as a coarse first layer, for example requiring authentication for /api/** and an admin role for /admin/**. Then use method security, such as @PreAuthorize("hasAuthority('orders:write')"), on service methods for fine-grained checks that can also use method arguments.
Finish URL rules with anyRequest().authenticated() or denyAll() so that new endpoints are never accidentally public.
Q14. What is the difference between a 401 and a 403 response?
The ExceptionTranslationFilter decides. If the user is not authenticated and an AuthenticationException occurs, it calls the AuthenticationEntryPoint, which typically returns 401 Unauthorized or redirects to a login page. If an authenticated user lacks permission, an AccessDeniedException goes to the AccessDeniedHandler, which returns 403 Forbidden.
Customise both to return consistent JSON errors in REST APIs.
Q15. Should I disable CSRF protection?
Keep CSRF protection enabled for applications that use browser sessions or cookies to authenticate, because browsers send cookies automatically and attackers can exploit that. It is reasonable to disable it for a stateless API that authenticates every request with a token in the Authorization header and does not use cookies.
If a single-page app uses cookie-based sessions, keep CSRF on and send the token in a header, for example using CookieCsrfTokenRepository.
Q16. What is the difference between permitAll() and web.ignoring()?
permitAll() lets anyone access a URL, but the request still passes through the security filter chain, so security headers, CSRF handling and the security context still apply. WebSecurityCustomizer with web.ignoring() skips Spring Security completely for those paths.
Prefer permitAll(); use ignoring() only for truly static resources where you want no security processing at all.
Q17. How do I add a custom filter, such as one for API keys?
Write a class that extends OncePerRequestFilter, validate the credential, and on success place an authenticated Authentication in the SecurityContextHolder. Register it with http.addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class) so it runs at the right point in the chain.
If the filter is a Spring bean, also stop Spring Boot from registering it a second time as a plain servlet filter, for example with a disabled FilterRegistrationBean.
Q18. How do I make a REST API stateless with JWT?
Configure the chain as an OAuth 2.0 resource server with http.oauth2ResourceServer(o -> o.jwt(...)). The BearerTokenAuthenticationFilter then reads the token from the Authorization header, validates its signature and claims, and creates the Authentication. Set SessionCreationPolicy.STATELESS so no HTTP session is created.
Let a dedicated authorization server issue tokens rather than writing your own token logic.
Q19. How can I see which security filters run and why a request fails?
Set logging.level.org.springframework.security=TRACE to log each filter as it runs and every authentication and authorization decision. At startup, Spring Security also logs the filters in each SecurityFilterChain.
For local development only, @EnableWebSecurity(debug = true) prints detailed request information; never enable it in production because it can log sensitive data.
Q20. How do I test secured endpoints?
Add spring-security-test. With MockMvc, annotate tests with @WithMockUser(roles = "ADMIN") or use request post-processors such as user(...), jwt() and csrf() to simulate different callers. Write tests for both the allowed and the denied cases, checking for 401 and 403 responses.
Use @WithUserDetails when the test should load a real user through your UserDetailsService.