Spring Security is the standard way to add authentication and authorization to Spring applications. It has a reputation for being magic. You add one dependency and every endpoint suddenly demands a login, and later a configuration change quietly opens an admin URL. The magic goes away once you see the design. Spring Security is a chain of servlet filters that runs before your controller, a small set of interfaces for proving who the caller is, and another small set for deciding what that caller may do.

This article builds that model from the request inward, then applies it. It covers a two-chain configuration for an API and a web app, a JWT resource server with custom role mapping, method-level rules, sessions and CSRF, and tests that prove the rules. Code targets Spring Security 7 with Spring Boot 4. The lambda DSL shown also works on 6.x, and the section on 7.0 lists what was removed. The protocols themselves, such as JWT validation, CSRF and PKCE, have their own pages, linked below.

Advertisement

The model: filters in front of your code

A servlet container passes each request through a list of filters before it reaches the DispatcherServlet. Spring Security registers one of them, DelegatingFilterProxy. It is a bridge from the container's filter list to a Spring bean, FilterChainProxy. FilterChainProxy holds one or more SecurityFilterChain beans. Each chain has a request matcher and an ordered list of security filters. For each request, FilterChainProxy picks the first chain whose matcher matches and runs only that chain's filters.

The filters in a chain each do one job. SecurityContextHolderFilter loads any existing security context, for example from the HTTP session. CsrfFilter checks the CSRF token on state-changing requests. Authentication filters look for credentials: a login form post, a bearer token or a Basic header. ExceptionTranslationFilter turns security exceptions into responses. AuthorizationFilter, near the end, decides whether the now-known caller may reach this URL. If every filter passes, the request continues to your controller with the caller available from SecurityContextHolder.

Spring Security runs as servlet filters, before DispatcherServlet and your controllerHTTP requestDelegatingFilterProxyservlet container filterFilterChainProxypicks the first matchchain 1: /api/**chain 2: everything elseFilters inside one SecurityFilterChain (simplified, in order)SecurityContextHolderFilterCsrfFilterauthenticationform, bearer, basicExceptionTranslationFilterAuthorizationFilterallow or deny401 becomes an AuthenticationEntryPoint response; 403 goes to the AccessDeniedHandlerAuthenticationManagerProviderManagerAuthenticationProviderDao, JWT, LDAP ...UserDetailsService+ PasswordEncoderdelegatesOn success the Authentication is stored in the SecurityContext for the rest of the request.
Request path through Spring Security. The first matching SecurityFilterChain handles the request; authentication filters delegate to the AuthenticationManager, and AuthorizationFilter makes the final URL decision.

Two consequences matter in practice. Because security runs before Spring MVC, a 401 or 403 is produced before your controller, your exception handlers or your logging aspects ever run. And because FilterChainProxy uses the first matching chain, chain order decides which rules apply. A catch-all chain registered first will swallow every other chain's traffic.

Authentication: proving who the caller is

An Authentication object plays two roles. Before authentication it carries the presented credentials, such as a username and password or a raw token. After authentication it carries the principal and its granted authorities, and reports isAuthenticated as true. Authentication filters build the unauthenticated version and hand it to an AuthenticationManager. The usual implementation, ProviderManager, tries each AuthenticationProvider that supports that token type, in order, until one returns an authenticated object. If none succeeds, the caller gets an AuthenticationException.

For username and password, DaoAuthenticationProvider loads the user through your UserDetailsService and compares the presented password with the stored hash using a PasswordEncoder. Use the delegating encoder from PasswordEncoderFactories. It stores hashes with a prefix such as {bcrypt}, so you can move to a stronger algorithm later and upgrade each stored hash when its user next logs in. Never store reversible or unsalted passwords. Spring Security 7 also added Password4j-based encoders for Argon2, BCrypt, SCrypt, PBKDF2 and balloon hashing.

After success, the filter stores the authenticated object in a SecurityContext held by SecurityContextHolder. By default this is a thread-local, which is why code anywhere in the request thread can read the current user. It is also why work handed to another thread loses the user unless you wrap the executor, for example with DelegatingSecurityContextExecutor.

Advertisement

Authorization: URL rules and method rules

URL rules live in authorizeHttpRequests. Each requestMatchers line maps a pattern, and optionally an HTTP method, to a rule such as permitAll, authenticated, hasRole or hasAuthority. Rules are checked top to bottom and the first match wins, so put specific paths before general ones and end with anyRequest. Behind the DSL, each rule is an AuthorizationManager that returns a decision for the current Authentication.

Authorities are plain strings. A role is an authority with the ROLE_ prefix, so hasRole("ADMIN") checks for ROLE_ADMIN. Most bugs here are prefix mismatches: the identity provider sends admin, the code checks hasRole("ADMIN"), and every admin gets 403.

Method security covers rules that depend on the data rather than the URL. With @EnableMethodSecurity, @PreAuthorize on a service method evaluates an expression before the call. The expression can refer to method arguments and the current authentication, as in allowing a customer to read only their own orders. Put ownership checks here, on the service, so every entry point gets them, including message listeners and scheduled jobs that never pass through a URL rule.

Worked example: one application, two chains

Take a shop with a JSON API under /api used by a mobile app, and server-rendered account pages used in a browser. The two have different threat models. The API receives bearer tokens and keeps no session, so CSRF does not apply. The pages use a session cookie, so CSRF protection must stay on. One chain cannot express both cleanly. Two chains can.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {

    // Chain 1: stateless JSON API, bearer tokens only. Order matters: most specific first.
    @Bean
    @Order(1)
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST, "/api/orders").hasAuthority("SCOPE_orders:write")
                .anyRequest().authenticated())
            .oauth2ResourceServer(o -> o.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());   // no cookies are used to authenticate this chain
        return http.build();
    }

    // Chain 2: server-rendered pages with a session cookie and form login.
    @Bean
    @Order(2)
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/css/**").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form.loginPage("/login").permitAll())
            .logout(Customizer.withDefaults());   // CSRF stays on: this chain uses cookies
        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // stores "{bcrypt}..."
    }
}

The API chain uses securityMatcher so it only claims /api/** and is registered first with @Order(1). Inside it, the public product reads are listed before the admin and write rules, and anyRequest().authenticated() closes everything else. The web chain has no securityMatcher and takes all remaining traffic. If you swapped the order, the web chain would match /api requests first and send mobile clients redirects to a login page.

Walk one request through it. POST /api/orders arrives with a bearer token carrying scope orders:read. FilterChainProxy selects the API chain. The bearer token filter validates the JWT and builds an authentication with authority SCOPE_orders:read. AuthorizationFilter reaches the POST /api/orders rule, finds that SCOPE_orders:write is missing, and the client receives 403. The same request with no token gets 401 with a WWW-Authenticate: Bearer header, because no authentication was ever established.

JWT resource servers

For an API protected by an OAuth 2.0 or OpenID Connect provider, configure the resource server with the issuer. At startup Spring discovers the provider's JWK set and builds a decoder that checks the signature, expiry, not-before and issuer on every token. The reasoning behind each of those checks is covered in JWT validation.

# application.yml: the decoder discovers the JWK set from the issuer's metadata
# and validates signature, exp, nbf and iss on every request.
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://login.example.com/realms/shop

By default the scope or scp claim becomes authorities prefixed with SCOPE_. Identity providers put roles in different claims, so map them explicitly, and add audience validation if several APIs share one issuer. Validating the issuer alone does not stop a token minted for another API from being accepted by yours.

// Map an identity provider's "roles" claim to ROLE_ authorities, alongside SCOPE_ ones.
JwtAuthenticationConverter jwtConverter() {
    JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); // scope/scp -> SCOPE_x
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        Collection<GrantedAuthority> out = new ArrayList<>(scopes.convert(jwt));
        List<String> roles = jwt.getClaimAsStringList("roles");
        if (roles != null) roles.forEach(r -> out.add(new SimpleGrantedAuthority("ROLE_" + r)));
        return out;
    });
    converter.setPrincipalClaimName("sub");
    return converter;
}

@Service
class OrderService {
    @PreAuthorize("hasRole('ADMIN') or #customerId == authentication.name")
    public List<Order> ordersFor(String customerId) { ... }
}

Opaque tokens are the alternative: the API asks the provider's introspection endpoint about each token. This allows instant revocation at the cost of a network call per request, a trade-off covered in JWT versus session tokens. Browser clients should obtain tokens with the authorization code flow and PKCE, as in OAuth 2.0 with PKCE.

Sessions, the security context and CSRF

In a session-based chain, SecurityContextHolderFilter loads the context from the HttpSession at the start of each request. Since 6.0, saving the context is explicit. Built-in login filters save it for you, but if you authenticate users in your own controller you must call SecurityContextRepository.saveContext yourself. Otherwise the user appears to log in and is anonymous on the next request. Change the session ID on login, which the default session fixation protection does, and set cookies to Secure, HttpOnly and SameSite.

CSRF protection is on by default because a browser attaches cookies to cross-site requests automatically. Spring Security issues a token and requires it on POST, PUT, PATCH and DELETE. Since 6.0 the token is masked per request to defend against BREACH and loaded lazily. Single-page apps that read the token from a cookie need matching configuration, and 7.0 added csrf.spa() as a shortcut for that setup. Disable CSRF only on chains that never authenticate with cookies, as the API chain above does. The wider defence, including SameSite and origin checks, is in CSRF defense architecture.

Multi-factor authentication in 7.0

Spring Security 7.0 added built-in multi-factor support. Each authentication mechanism adds a factor authority to the resulting Authentication, such as FACTOR_PASSWORD or FACTOR_OTT. Annotating a configuration with @EnableMultiFactorAuthentication, listing for example FactorGrantedAuthority.PASSWORD_AUTHORITY and FactorGrantedAuthority.OTT_AUTHORITY, makes every authorization rule also require those factors. 7.0 also added support for pointing a user who lacks a factor at the mechanism that supplies it, instead of a bare 403. 7.1 added conditions to the multi-factor authorization factory, so you can require a second factor only in some situations. Check the reference documentation for your exact minor version before relying on these newer APIs.

Testing the rules

Security rules are code, and they regress like code. The spring-security-test module adds request post-processors and annotations to MockMvc: jwt() for bearer tokens with chosen authorities, user() and @WithMockUser for session users, and csrf() to attach a valid token. Test the negative cases first, because permissive bugs are the dangerous ones.

@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderSecurityTest {
    @Autowired MockMvc mvc;

    @Test void anonymousGets401() throws Exception {
        mvc.perform(post("/api/orders")).andExpect(status().isUnauthorized());
    }

    @Test void wrongScopeGets403() throws Exception {
        mvc.perform(post("/api/orders").with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:read"))))
           .andExpect(status().isForbidden());
    }

    @Test void rightScopePasses() throws Exception {
        mvc.perform(post("/api/orders").contentType(APPLICATION_JSON).content("{}")
               .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:write"))))
           .andExpect(status().isAccepted());
    }
}

Failure modes

SymptomLikely causeFix
Every POST returns 403 in the browserCSRF token not sentSend the token from the form or cookie; use csrf.spa() for SPAs
Admins get 403Authority is admin, rule checks ROLE_ADMINMap claims to ROLE_ authorities in the converter
API clients get a login page redirectWeb chain matched firstsecurityMatcher on the API chain and @Order(1)
Logged in, anonymous on the next requestCustom login never saved the contextCall SecurityContextRepository.saveContext
User missing in an @Async methodThread-local context not propagatedWrap the executor with DelegatingSecurityContextExecutor
Token for another API acceptedOnly issuer validatedAdd an audience validator

Migrating to 7.0

Spring Security 7.0 removed the and() method and the non-lambda configuration style, so every configuration must use the lambda DSL shown here. It also removed authorizeRequests in favour of authorizeHttpRequests, and removed AntPathRequestMatcher and MvcRequestMatcher in favour of PathPatternRequestMatcher. Support for the OAuth 2.0 password grant is gone. A practical path is to move to the latest 6.x release first, fix every deprecation warning there, and only then upgrade.

What to do next

  1. List your traffic types, such as API, browser pages and actuator endpoints, and give each its own SecurityFilterChain with an explicit securityMatcher and @Order.
  2. Rewrite URL rules most-specific-first and end every chain with anyRequest().authenticated() or denyAll().
  3. Use the delegating PasswordEncoder, and confirm stored hashes carry an algorithm prefix.
  4. For JWT APIs, set issuer-uri, add audience validation and map role claims explicitly with a JwtAuthenticationConverter.
  5. Move ownership checks to @PreAuthorize on service methods so non-HTTP entry points are covered.
  6. Write MockMvc tests for 401, 403 and success on every protected endpoint, and run them in CI.
  7. Remove any and() calls, authorizeRequests and Ant matchers now, so the 7.0 upgrade is mechanical.
Key takeaway: Spring Security is a chain of servlet filters chosen per request by FilterChainProxy, where the first matching SecurityFilterChain wins. Authentication filters hand credentials to an AuthenticationManager and store the result in a thread-local SecurityContext, and AuthorizationFilter and method security decide access from authorities that are plain strings with prefixes. Use separate chains for token APIs and cookie sessions, keep CSRF on wherever cookies authenticate, map identity provider claims explicitly, test the deny cases, and use only the lambda DSL that 7.0 requires.