Spring Security

The filter chain architecture behind authentication and authorization โ€” and where session-based and stateless (JWT) configuration genuinely diverge

← Back to Index

What is Spring Security, Architecturally?

Spring Security is a chain of Filters โ€” plain Servlet filters, the same mechanism covered in HTTP Protocol & Methods and contrasted with Spring's own interceptors in Spring MVC โ€” inserted in front of your application via a single DelegatingFilterProxy. That proxy delegates to a FilterChainProxy, which holds an ordered list of SecurityFilterChain beans, each guarding a set of URL patterns. The first chain whose pattern matches the request wins โ€” everything inside it (authentication, CSRF, authorization decisions) runs before the request ever reaches DispatcherServlet.

This page is about Spring Security's mechanics โ€” not "what is authentication"

The conceptual distinction between authentication and authorization is covered on its own dedicated page: Authentication vs Authorization. This page assumes that distinction and focuses on how Spring Security specifically implements it โ€” the filter chain, the AuthenticationManager, and the two fundamentally different configuration shapes (session-based vs stateless) that follow from it.

If a tutorial uses WebSecurityConfigurerAdapter, it's outdated

WebSecurityConfigurerAdapter โ€” extending a base class and overriding configure(HttpSecurity) โ€” was deprecated in Spring Security 5.7 and removed entirely in Spring Security 6 (Spring Boot 3+). The current, and only, approach is exposing a SecurityFilterChain as a @Bean, using the lambda DSL shown throughout this page. A huge amount of security tutorials online still teach the removed API โ€” treat that as a strong signal the content predates Boot 3.

Setup and the Default Behaviour

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
The dependency alone changes your app's behaviour โ€” before you write a line of config

Auto-configuration registers a default SecurityFilterChain that requires authentication for every endpoint, serves a default login form at /login, and generates a random password for an in-memory user named user, printed once to the console at startup. This is a deliberately restrictive default โ€” the framework assumes an unconfigured app should fail closed, not open.

The Authentication Flow โ€” What Actually Happens

/*
 *  Login request โ”€โ”€โ–ถ UsernamePasswordAuthenticationFilter
 *                              โ”‚  builds an unauthenticated Authentication (username + password)
 *                              โ–ผ
 *                    AuthenticationManager
 *                              โ”‚  delegates to the first AuthenticationProvider that supports it
 *                              โ–ผ
 *                    DaoAuthenticationProvider
 *                              โ”‚  1. UserDetailsService.loadUserByUsername(username)
 *                              โ”‚  2. PasswordEncoder.matches(rawPassword, storedHash)
 *                              โ–ผ
 *                    Authenticated Authentication object
 *                              โ”‚  (principal + authorities, password discarded)
 *                              โ–ผ
 *                    SecurityContextHolder.getContext().setAuthentication(...)
 *                              โ”‚  stored for the rest of THIS request (and the session, if any)
 */

Every piece here is replaceable independently. Provide your own UserDetailsService to load users from a database instead of memory; the AuthenticationManager/DaoAuthenticationProvider machinery around it stays exactly the same. This is why a custom database-backed UserDetailsService, further down, plugs in without touching anything else in the configuration.

Session-Based Configuration (Form Login)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                // Order matters: rules are evaluated top to bottom, first match wins.
                // A specific /admin/** rule AFTER a catch-all anyRequest() would never run.
                .requestMatchers("/public/**", "/login").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/login?logout")
                .permitAll()
            );
            // CSRF stays ENABLED here โ€” the default, and correct, for a session-based app

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
hasRole("ADMIN") vs hasAuthority("ROLE_ADMIN") โ€” a silent authorization bug waiting to happen

hasRole("ADMIN") automatically prefixes ROLE_ under the hood โ€” it actually checks for the authority "ROLE_ADMIN". If your UserDetailsService already stores authorities with the prefix baked in and a rule elsewhere uses hasAuthority("ROLE_ADMIN") (no auto-prefixing), both happen to work โ€” but mixing conventions across a codebase, or double- prefixing to "ROLE_ROLE_ADMIN", produces an authorization check that silently never matches. No exception, no log โ€” the user is just always denied. Pick one convention (hasRole + unprefixed stored authorities is the idiomatic default) and stay consistent.

UserDetailsService โ€” Where Users Actually Come From

In-memory (testing only)

@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
    UserDetails user = User.builder()
        .username("user")
        .password(passwordEncoder.encode("password"))
        .roles("USER")
        .build();

    return new InMemoryUserDetailsManager(user);
}
Injecting PasswordEncoder as a parameter, not calling the @Bean method directly

Calling passwordEncoder() directly from inside another @Bean method in the same @Configuration class does still return the singleton โ€” Spring CGLIB-proxies full @Configuration classes precisely so internal @Bean-to-@Bean calls resolve correctly. Declaring it as a method parameter instead, as above, is preferred style regardless โ€” it works identically if the encoder bean ever moves to a different configuration class, and it doesn't rely on the reader knowing about CGLIB proxying to trust it's correct.

Database-backed (real applications)

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        AppUser appUser = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

        // stored password is ALREADY a BCrypt hash โ€” never re-encode it here
        return org.springframework.security.core.userdetails.User.builder()
            .username(appUser.getUsername())
            .password(appUser.getPassword())
            .authorities(appUser.getRoles().stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r.getName()))
                .toList())
            .disabled(!appUser.isEnabled())
            .build();
    }
}
Password hashing itself is covered on its own page

Spring Security's default, BCryptPasswordEncoder, remains a solid choice and needs no configuration to use correctly. For the reasoning behind salted hashing, work-factor tuning, and where Argon2 fits in, see Password Hashing โ€” this page only covers how the encoder plugs into Spring Security's authentication flow.

Stateless Configuration (JWT) โ€” a Genuinely Different Shape, Not a Variant

A REST API with no server-side session needs a different SecurityFilterChain altogether โ€” not the form-login one from Section 3 with a filter bolted on. Pick one shape per application, not both.

@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;

    public JwtSecurityConfig(JwtAuthenticationFilter jwtAuthFilter) {
        this.jwtAuthFilter = jwtAuthFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)  // see the warning below BEFORE copying this line
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // no HttpSession is ever created
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}
Why CSRF is disabled here โ€” and the exact condition under which that's wrong

CSRF exploits the browser's behaviour of automatically attaching cookies to any request to the site that set them โ€” a malicious page can trigger a request your browser silently authenticates via the session cookie. A bearer token sent in an Authorization header must be attached explicitly by your own JavaScript; a forged cross-site request has no way to add that header, so the attack vector genuinely doesn't apply โ€” disabling CSRF here is correct, not a shortcut.

This reasoning breaks entirely if the JWT is instead stored in a cookie (common for XSS-mitigation reasons, using HttpOnly). A cookie-stored token is attached automatically by the browser, exactly like a session cookie โ€” CSRF protection is still required in that case, disabling it re-opens the exact vulnerability this section just explained why it was safe to skip.

// The filter's only job: read the header, validate the token, populate SecurityContext.
// Token issuing/validation logic (JwtService) is covered on the dedicated JWT page.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String jwt = authHeader.substring(7);
        String username = jwtService.extractUsername(jwt);

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            if (jwtService.isTokenValid(jwt, userDetails)) {
                var authToken = new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }
}
throws ServletException, IOException โ€” not throws Exception

The overridden method is declared by OncePerRequestFilter as throws ServletException, IOException. Java does not permit an overriding method to declare a broader checked exception than the method it overrides โ€” declaring throws Exception here, as some tutorials do, is a genuine compile error, not a style preference.

Full token issuing and validation: see the JWT page

JwtService's internals โ€” signing algorithm, expiry handling, refresh tokens, secret/key management โ€” are covered in depth on JWT (JSON Web Tokens). This section only covers how a token, once validated, gets plugged into Spring Security's SecurityContext via a custom filter.

Method-Level Security

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {}

@Service
public class UserService {

    @PreAuthorize("hasRole('ADMIN')")                              // checked BEFORE the method body runs
    public void deleteUser(Long id) { }

    @PreAuthorize("#userId == authentication.principal.id")          // can reference method parameters directly
    public User getProfile(Long userId) { ... }

    @PostAuthorize("returnObject.owner == authentication.name")        // checked AFTER โ€” needs the return value to decide
    public Document getDocument(Long id) { ... }

    @PostFilter("filterObject.owner == authentication.name")         // filters the returned collection element-by-element
    public List<Document> getAllDocuments() {
        return documentRepository.findAll();
    }
}
@PostFilter is an in-memory filter, not a WHERE clause โ€” the same lesson as N+1

getAllDocuments() above calls documentRepository.findAll() โ€” every document row is fetched from the database first. @PostFilter then discards the ones that don't match the SpEL expression, entirely in Java, after the fact. On a table with a million documents belonging to thousands of users, this fetches all of them to return maybe a dozen. As with the N+1 problem covered in Spring Data JPA, the fix is pushing the condition down into the query itself โ€” a findByOwner(owner) derived method or a Specification โ€” and reserving @PostFilter for collections that are already small by the time they reach this layer.

Accessing the Current User

@RestController
public class ProfileController {

    // Preferred โ€” resolved directly from SecurityContext by an argument resolver,
    // no manual lookup, works with your own UserDetails implementation too
    @GetMapping("/profile")
    public String getProfile(@AuthenticationPrincipal UserDetails user) {
        return "Hello, " + user.getUsername();
    }

    // Equivalent, framework-agnostic โ€” Authentication implements java.security.Principal
    @GetMapping("/profile-principal")
    public String getProfileViaPrincipal(Principal principal) {
        return "Hello, " + principal.getName();
    }
}
Reach for SecurityContextHolder directly only outside a request-scoped bean

@AuthenticationPrincipal is resolved through the exact same SecurityContextHolder both alternatives ultimately read from, so inside a controller there's no functional difference โ€” just less boilerplate. Calling SecurityContextHolder.getContext().getAuthentication() directly remains necessary in code that has no access to the request at all, such as a scheduled batch job or a domain service several layers away from any controller parameter.

CORS vs CSRF โ€” Not the Same Problem

These get confused because both are three-letter acronyms about cross-origin requests, but they solve opposite problems. CORS is the browser relaxing its same-origin policy so your own frontend, on a different origin, is allowed to call your API. CSRF is a defence against a request the browser sends without the user's intent, triggered by a malicious third-party page. Configuring one has no effect on the other.

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));  // never "*" if allowCredentials is true
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}
// Wired in: http.cors(cors -> cors.configurationSource(corsConfigurationSource()))

Testing Secured Endpoints

@WebMvcTest(UserController.class)
class UserControllerSecurityTest {

    @Autowired private MockMvc mockMvc;

    @Test
    void deleteUser_asAdmin_succeeds() throws Exception {
        mockMvc.perform(delete("/admin/users/1").with(csrf()))
            .andExpect(status().isNoContent());
    }

    @Test
    @WithMockUser(roles = "USER")  // simulates an authenticated USER โ€” no real login flow needed
    void deleteUser_asRegularUser_isForbidden() throws Exception {
        mockMvc.perform(delete("/admin/users/1").with(csrf()))
            .andExpect(status().isForbidden());
    }
}
@WithMockUser sidesteps the entire authentication flow โ€” deliberately

It injects a pre-built Authentication straight into the test's SecurityContext, skipping UserDetailsService and PasswordEncoder entirely. That's correct for testing authorization rules โ€” which roles can reach which endpoint. It deliberately does not exercise the login flow itself; a real @SpringBootTest hitting /login is what verifies that part.

Interview Questions

๐ŸŽ“ Junior level

Q: What does adding spring-boot-starter-security alone do to an app?
It secures every endpoint by default, requiring authentication, and serves a default login form with a single generated in-memory user โ€” a deliberately restrictive default rather than an opt-in one.

Q: What is the role of PasswordEncoder, and why can't you compare passwords with .equals()?
Passwords are stored as a one-way hash, never in plain text. PasswordEncoder.matches(raw, hash) re-hashes the raw input with the same algorithm and salt and compares the result โ€” you cannot reverse a hash back to plain text to compare it directly.

Q: What's the difference between @AuthenticationPrincipal and Principal in a controller?
Both ultimately read from the same SecurityContext. Principal is the generic java.security interface; @AuthenticationPrincipal can be typed as your actual UserDetails implementation, giving direct access to custom fields without an extra cast.

๐Ÿ”ฅ Senior level

Q: Walk through what happens, component by component, from a login POST to an authenticated SecurityContext.
UsernamePasswordAuthenticationFilter builds an unauthenticated Authentication from the submitted credentials and hands it to the AuthenticationManager, which delegates to the first AuthenticationProvider that supports it โ€” typically DaoAuthenticationProvider, which calls UserDetailsService.loadUserByUsername and then PasswordEncoder.matches. On success, a fully authenticated Authentication (principal + authorities, password discarded) is stored in SecurityContextHolder for the remainder of the request โ€” and the session, if one exists.

Q: Why is CSRF disabled for a JWT-based API, and under what condition does that reasoning stop applying?
CSRF exploits the browser automatically attaching cookies to cross-site requests; a bearer token in an Authorization header must be added explicitly by application JavaScript, so a forged cross-site request has no way to include it โ€” the attack vector doesn't apply. This reasoning fails the moment the token is instead stored in a cookie (even HttpOnly): a cookie is attached automatically regardless of where the token lives, restoring exactly the vulnerability CSRF protection exists to prevent.

Q: Why can hasRole("ADMIN") and hasAuthority("ROLE_ADMIN") produce inconsistent results across a codebase?
hasRole silently prepends ROLE_ before checking; hasAuthority checks the literal string given. If authorities are stored with the prefix already included, mixing the two conventions โ€” or double-prefixing โ€” produces an authorization check that never matches for that user, with no exception and no log line, since "not authorized" and "misconfigured comparison" look identical from the outside.

Q: Why is @PostFilter potentially dangerous on a large dataset, and what's the correct fix?
@PostFilter evaluates its SpEL expression against a collection that has already been fully loaded โ€” typically by a repository's findAll() โ€” discarding non-matching elements in Java afterward. It never becomes a WHERE clause. On any non-trivial dataset, the fix is pushing the ownership condition into the query itself (a derived method or Specification), the same principle as fixing an N+1 query: filter where the database can use an index, not after every row has already been fetched.