00:00

Spring Security Architecture Explained

Last updated:

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.

Client (browser or API caller) HTTP request Servlet container (e.g. Tomcat) DelegatingFilterProxy Bridge from Tomcat to Spring beans FilterChainProxy Picks the first SecurityFilterChain that matches SecurityFilterChain selected filters, in order SecurityContextHolderFilter CsrfFilter Authentication filter form · Basic · bearer ExceptionTranslationFilter AuthorizationFilter allow or deny access granted DispatcherServlet → your controller Your code runs only after every check passes SecurityContextHolder Holds the logged-in user for this request Authentication components AuthenticationManager implemented by ProviderManager DaoAuthentication Provider form · Basic JwtAuthentication Provider bearer JWT UserDetailsService PasswordEncoder JwtDecoder 1 2 3
  1. The authentication filter asks the AuthenticationManager to check the credentials.
    • Form and Basic logins go to DaoAuthenticationProvider, which uses UserDetailsService and PasswordEncoder.
    • Bearer JWTs go to JwtAuthenticationProvider, which uses JwtDecoder.
  2. On success, the filter puts the result in the SecurityContextHolder.
  3. AuthorizationFilter later 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.

FilterWhat it does
SecurityContextHolderFilterLoads any existing security information (for example, from the HTTP session) at the start of the request.
CsrfFilterProtects against cross-site request forgery by checking a CSRF token on requests that change data.
LogoutFilterHandles logout requests and clears the user's security information.
UsernamePasswordAuthenticationFilterReads the username and password submitted from a login form and starts authentication.
BasicAuthenticationFilterReads credentials from an HTTP Basic Authorization header.
BearerTokenAuthenticationFilterReads a bearer token, such as a JWT, from the Authorization header.
AnonymousAuthenticationFilterMarks the request as anonymous if nobody has logged in yet.
ExceptionTranslationFilterTurns security exceptions into the right HTTP response, such as a login redirect, 401, or 403.
AuthorizationFilterMakes 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:

Java
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:

AppUserDetailsService.java
@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:

Java
@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

How they work together

When a user submits a login form, these steps happen in order:

  1. UsernamePasswordAuthenticationFilter reads the username and password and wraps them in an unauthenticated Authentication object.
  2. The filter passes it to the AuthenticationManager (a ProviderManager).
  3. ProviderManager finds a provider that supports it, usually DaoAuthenticationProvider.
  4. The provider calls UserDetailsService to load the user from the database.
  5. The provider uses PasswordEncoder to check the submitted password against the stored hash.
  6. If everything matches, the provider returns an authenticated Authentication object containing the user's roles.
  7. The filter stores that object in the SecurityContext, which is covered next. If anything fails, an AuthenticationException is thrown instead.
UsernamePassword AuthenticationFilter Provider Manager DaoAuthentication Provider UserDetails Service Password Encoder SecurityContext Holder 1 · wrap credentials 2 · authenticate(token) 3 · authenticate(token) 4 · loadUserByUsername() UserDetails 5 · matches(raw, hash) true 6 · authenticated token credentials erased 7 · setContext(new context with token) On failure (unknown user or wrong password): the SecurityContextHolder is cleared and the filter's AuthenticationFailureHandler runs. ExceptionTranslationFilter is not involved.
The numbers match the steps above. Solid arrows are calls and dashed arrows are return values. In step 7 the filter creates a new 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.

  • SecurityContext is a small container that holds the current user's Authentication object.
  • SecurityContextHolder gives you access to that container from anywhere in your code.
Request thread A SecurityContextHolder.getContext() SecurityContext Authentication getName(): Mohan principal: UserDetails authorities: ROLE_USER Request thread B SecurityContextHolder.getContext() SecurityContext Authentication getName(): Komal principal: UserDetails authorities: ROLE_ADMIN End of request: FilterChainProxy clears the context
By default, 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:

Java
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:

Java
@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:

Java
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:

MethodSecurityConfig.java
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig { }
OrderService.java
@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:

SituationExceptionHandled byTypical response
The user is not logged inAuthenticationExceptionAuthenticationEntryPointRedirect to the login page, or 401 Unauthorized for an API
The user is logged in but lacks permissionAccessDeniedExceptionAccessDeniedHandler403 Forbidden
AccessDeniedException or AuthenticationException thrown later in the chain, e.g. by AuthorizationFilter caught by ExceptionTranslationFilter not logged in / AuthenticationException logged in, access denied Start authentication · clear the SecurityContextHolder · save the request in the RequestCache · call the AuthenticationEntryPoint AccessDeniedHandler called for logged-in users Form login: redirect to the login page Bearer token: 401 + WWW-Authenticate 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:

Java
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:

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Then create the configuration:

SecurityConfig.java
@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:

ConfigurationArchitecture component
SecurityFilterChain beanThe chain that FilterChainProxy runs for every request
.formLogin(...)Adds UsernamePasswordAuthenticationFilter
.authorizeHttpRequests(...)Configures AuthorizationFilter and its AuthorizationManager
.logout(...)Configures LogoutFilter
@EnableMethodSecurityTurns on @PreAuthorize and @PostAuthorize
PasswordEncoder beanUsed by DaoAuthenticationProvider to check passwords
UserDetailsService beanUsed 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.