luap.in/java
← Index · 05 · Framework Spring Boot 3.x · Spring Security 6

Spring MVC and the security filter chain

Spring Security is a servlet Filter; Spring MVC is a Servlet. Everything about authorization ordering follows from that, including the fact that a method-security annotation runs after the request body has already been bound to your object.

01Two mechanisms, two positions

Spring enforces authorization in two places that behave very differently, and conflating them produces most Spring Security findings.

Filter chainMethod security
Implemented asFilterChainProxy, a servlet filterAn AOP proxy around the bean
RunsBefore DispatcherServletAfter binding, at method invocation
SeesThe raw HTTP requestResolved method arguments
Matches onPath, method, headersThe method being called
NeedsSecurityFilterChain bean@EnableMethodSecurity
The silent failure

@PreAuthorize and @PostAuthorize do nothing unless method security is switched on. Without @EnableMethodSecurity the annotations compile, pass review, and are never enforced — no warning, no log line, no startup error.

@Configuration
@EnableMethodSecurity     // without this, @PreAuthorize is a comment
public class SecurityConfig { … }

When you see @PreAuthorize in a codebase, grep for @EnableMethodSecurity before treating it as a control. Its absence is a finding on its own.

02The security filter chain

A single FilterChainProxy is registered in the servlet container. On each request it picks the first SecurityFilterChain whose matcher accepts the request, then runs that chain's internal filters in a fixed order.

// Spring Security 6 default chain, abbreviated, in execution order
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter          // loads SecurityContext from the session
HeaderWriterFilter                   // X-Frame-Options, HSTS, …
CorsFilter
CsrfFilter                           // rejects unsafe methods without a token
LogoutFilter
UsernamePasswordAuthenticationFilter // form login
BasicAuthenticationFilter
RequestCacheAwareFilter
AnonymousAuthenticationFilter        // gives unauthenticated users a principal
SessionManagementFilter
ExceptionTranslationFilter           // turns AccessDeniedException into 403/redirect
AuthorizationFilter                  // LAST — this is where access is decided

Two details that matter in review:

  • AuthorizationFilter is last. Everything before it — CSRF, CORS, header writing, authentication — has already run. In Spring Security 6 this class replaced FilterSecurityInterceptor; a codebase or tutorial referencing the old name is pre-6.
  • AnonymousAuthenticationFilter means an unauthenticated request still has an Authentication object. Code that checks authentication != null as a proxy for "logged in" is therefore always true. Check the authorities, or isAuthenticated() combined with not being anonymous.
// Always true, even for an anonymous request
if (SecurityContextHolder.getContext().getAuthentication() != null) {
    // "authenticated"
}

03Matcher order decides everything

Within authorizeHttpRequests, rules are evaluated top to bottom and the first match wins. A broad rule placed above a narrow one makes the narrow one unreachable.

@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(a -> a
        .requestMatchers("/api/**").permitAll()            // matches first…
        .requestMatchers("/api/admin/**").hasRole("ADMIN")  // …so this never runs
        .anyRequest().authenticated());
    return http.build();
}
http.authorizeHttpRequests(a -> a
    .requestMatchers("/api/admin/**").hasRole("ADMIN")   // specific first
    .requestMatchers("/api/public/**").permitAll()
    .anyRequest().authenticated());                   // deny-by-default last

The same ordering rule applies one level up, between multiple SecurityFilterChain beans: the first whose securityMatcher accepts the request wins, and the rest are not consulted. Chain order comes from @Order, and two chains without an explicit order is a bug waiting for a redeploy to surface.

Verify, do not read

# Log the resolved chain and its filters at startup
logging.level.org.springframework.security=DEBUG

# Spring Security prints the chain on startup, e.g.
Will secure Or [Ant [pattern='/api/admin/**']] with filters:
  DisableEncodeUrlFilter, …, AuthorizationFilter

For a non-trivial configuration this output is the only reliable statement of what is protected. A matcher that looks right in Java can still be shadowed.

permitAll is not "no security"

permitAll() exempts a path from authorization. CSRF, CORS and header-writing filters still run, and method security still applies if enabled. Conversely, a path not matched by any chain is not protected by Spring Security at all — it is served by the container directly. Actuator endpoints, error pages and static resources are the usual residents of that gap.

04Inside doDispatch

Once the security chain passes the request on, DispatcherServlet takes over.

01
getHandler(request)

RequestMappingHandlerMapping matches method, path, params, headers and content type against the registered @RequestMapping metadata, returning a HandlerExecutionChain.

02
applyPreHandle()

Runs each HandlerInterceptor. These are Spring's own interceptors — not servlet filters, not the security chain. Returning false ends the request here.

03
getMethodArgumentValues()

ServletInvocableHandlerMethod walks the controller method's parameters and asks each registered HandlerMethodArgumentResolver to produce a value. This is where the request becomes your objects.

04
Validation

@Valid triggers the Bean Validation provider. Violations go into BindingResult — which does not stop the method if the controller declares a BindingResult parameter and ignores it.

05
Controller method invoked

If method security is enabled, the AOP proxy evaluates @PreAuthorize at this point — after step 03 has already run.

06
Return value handled

HttpMessageConverter serialises the result. For JSON this is Jackson, which writes every readable property unless told otherwise.

05Argument resolvers and WebDataBinder

AnnotationResolverMechanism
@RequestParamRequestParamMethodArgumentResolversingle value + type conversion
@PathVariablePathVariableMethodArgumentResolverfrom the URI template
@RequestHeaderRequestHeaderMethodArgumentResolverheader value
@RequestBodyRequestResponseBodyMethodProcessorJackson readValue
@ModelAttributeModelAttributeMethodProcessorWebDataBinder over all params

@ModelAttribute is the dangerous one. WebDataBinder uses BeanWrapperImpl, which supports nested property paths — so a parameter named owner.id walks getOwner().setId(), exactly like Struts' OGNL binding.

@PostMapping("/profile")
String save(@ModelAttribute User user) {   // User is a JPA @Entity
    repo.save(user);
}
// POST displayName=x&role=ADMIN&enabled=true
// every setter on User is reachable, including nested ones
// Bind to a type that declares exactly what a user may change
public record ProfileForm(
    @NotBlank @Size(max = 80)  String displayName,
    @Size(max = 500)             String bio) {}

@PostMapping("/profile")
String save(@Valid @ModelAttribute ProfileForm form,
            BindingResult errors, Principal principal) {
    if (errors.hasErrors()) return "profile";   // must check explicitly

    User u = repo.findByUsername(principal.getName());  // identity from session
    u.setDisplayName(form.displayName());
    u.setBio(form.bio());                           // explicit, field by field
    repo.save(u);
    return "redirect:/profile";
}

Two controls in that block, not one. The DTO bounds what can be written; taking the identity from Principal bounds whose record is written. A DTO alone still allows editing someone else's profile if the id comes from the request.

Not a fix: @InitBinder deny-lists
@InitBinder
void init(WebDataBinder b) {
    b.setDisallowedFields("role", "enabled");   // deny-list
}

It goes stale the moment anyone adds a field to the entity, and it lives in a different file from the class it is guarding. setAllowedFields is better but still remote from the entity. A DTO makes the writable surface a declaration in the type system, which is why it is the actual fix.

06Jackson in both directions

Inbound

Spring Boot sets FAIL_ON_UNKNOWN_PROPERTIES to false, so unexpected JSON fields are ignored rather than rejected. That limits mass assignment through @RequestBody — but only to properties the target type actually has. If the target is your entity, every property it has is still bindable.

Polymorphic deserialization
mapper.activateDefaultTyping(                       // or the older enableDefaultTyping
    LaissezFaireSubTypeValidator.instance,
    ObjectMapper.DefaultTyping.NON_FINAL);

This makes Jackson instantiate a class named in the JSON itself. It is the same problem as Java deserialization, reached through a different parser, and it has produced a long series of CVEs as new reachable classes were found. If you find it enabled on a path that accepts untrusted JSON, treat it as critical.

The same applies to @JsonTypeInfo(use = Id.CLASS) on a type that appears in a request body.

Outbound

Returning an entity from a @RestController serialises every readable property. Fields nobody intended to publish leave the building:

@GetMapping("/api/users/{id}")
User get(@PathVariable long id) {
    return repo.findById(id).orElseThrow();
}
// {"id":1,"username":"paul","passwordHash":"$2a$12$…",
//  "resetToken":"…","internalNotes":"…","failedLogins":3}
public record UserView(long id, String username, String displayName) {
    static UserView of(User u) {
        return new UserView(u.getId(), u.getUsername(), u.getDisplayName());
    }
}

@GetMapping("/api/users/{id}")
UserView get(@PathVariable long id) {
    return UserView.of(repo.findById(id).orElseThrow());
}

@JsonIgnore on the entity works, but it is opt-out: the next field someone adds is exposed by default. A view record is opt-in, and adding a field to the entity cannot change the API response.

07Method security runs late

@PreAuthorize is enforced by an AOP proxy wrapping the bean. The request has already been routed, the body already parsed, the arguments already bound. For authorization that is usually fine. It matters when binding itself has side effects — a setter that writes to a cache, a custom deserialiser that performs a lookup, a converter that touches the database.

Because it is proxy-based, it also has the standard Spring AOP limitation:

@Service
public class ContactService {

    public Contact update(long id, ContactForm f) {
        return doUpdate(id, f);     // internal call — bypasses the proxy
    }

    @PreAuthorize("hasRole('ADMIN')")
    public Contact doUpdate(long id, ContactForm f) { … }
}

this.doUpdate(...) does not go through the proxy, so the annotation is not evaluated. The call has to cross a bean boundary for the advice to apply. This is the same self-invocation rule that catches @Transactional, and it is easy to miss because the annotation is right there in the file.

SpEL in the annotation is an expression sink

// If any part of this string is built from input, it is SpEL injection
@PreAuthorize("hasPermission(#id, '" + type + "', 'read')")

Annotation values must be compile-time constants, so the direct form is rare — but the same risk appears wherever an expression string is assembled at runtime and handed to a SpelExpressionParser. See the SpEL entry in the catalogue for the SimpleEvaluationContext fix.

08Triage checklist

# 1. Is method security actually on?
$ grep -rn '@EnableMethodSecurity\|@EnableGlobalMethodSecurity' src/
$ grep -rn '@PreAuthorize\|@PostAuthorize\|@Secured' src/ | wc -l
# annotations present but the enabler missing = finding

# 2. Matcher ordering and blanket permits
$ grep -rn -A12 'authorizeHttpRequests' src/ | grep -nE 'permitAll|anyRequest'

# 3. CSRF disabled — fine only if truly stateless and cookie-free
$ grep -rn 'csrf' src/ | grep -i disable

# 4. Mass assignment: entities bound directly from requests
$ grep -rnE '@(ModelAttribute|RequestBody)\s+[A-Z]' src/ \
    | while read -r l; do t=$(echo "$l" | grep -oE '[A-Z][A-Za-z]+ [a-z]' | cut -d' ' -f1); \
        grep -rln "class $t" src/ | xargs grep -ln '@Entity' 2>/dev/null \
        && echo "  ENTITY BOUND: $l"; done

# 5. Entities returned from controllers (outbound exposure)
$ grep -rnE '@(Get|Post|Put|Delete)Mapping' -A2 src/ | grep -E '^\s*[A-Z][A-Za-z]+ [a-z]+\('

# 6. Polymorphic Jackson
$ grep -rnE 'activateDefaultTyping|enableDefaultTyping|JsonTypeInfo' src/

# 7. Actuator exposure
$ grep -rn 'management.endpoints' src/main/resources/
Then confirm at runtime

Every grep above produces leads, not findings. Confirm with logging.level.org.springframework.security=DEBUG, which prints the resolved chain per request, and with a breakpoint on AuthorizationFilter#doFilter to see the decision for a specific URL. "The config appears to permit this" and "the filter permitted this" are different claims, and only the second belongs in a report.

Check yourself
  1. A service method carries @PreAuthorize("hasRole('ADMIN')") and is not enforced. What is the first thing to check?
    Whether @EnableMethodSecurity is present on a configuration class. Without it the annotations compile, pass review and are never evaluated — no warning, no log line, no startup error. Its absence is a finding in itself.
  2. requestMatchers("/**").permitAll() appears above requestMatchers("/api/admin/**").hasRole("ADMIN"). What happens?
    Rules are evaluated top to bottom and the first match wins, so the broad rule answers every request and the admin rule is unreachable. Order specific before general and finish with a deny-by-default anyRequest().authenticated(). The same first-match rule applies between multiple SecurityFilterChain beans, ordered by @Order.
  3. Does SecurityContextHolder.getContext().getAuthentication() != null mean the user is logged in?
    No — it is always true. AnonymousAuthenticationFilter gives unauthenticated requests an AnonymousAuthenticationToken, so the null check never fails. Check the authorities, or that the authentication is not an anonymous one.
  4. A public method calls this.doUpdate(...), where doUpdate is annotated @PreAuthorize. Is it enforced?
    No. Method security is proxy-based, and a self-invocation does not pass through the proxy, so the advice never applies. The call has to cross a bean boundary. This is the same rule that catches @Transactional, and it is easy to miss because the annotation is visible right there in the file.
luap.in/java · source Java 21 · Tomcat 10.1.61 · Struts 6 · Spring Boot 3