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 chain | Method security | |
|---|---|---|
| Implemented as | FilterChainProxy, a servlet filter | An AOP proxy around the bean |
| Runs | Before DispatcherServlet | After binding, at method invocation |
| Sees | The raw HTTP request | Resolved method arguments |
| Matches on | Path, method, headers | The method being called |
| Needs | SecurityFilterChain bean | @EnableMethodSecurity |
@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:
AuthorizationFilteris last. Everything before it — CSRF, CORS, header writing, authentication — has already run. In Spring Security 6 this class replacedFilterSecurityInterceptor; a codebase or tutorial referencing the old name is pre-6.AnonymousAuthenticationFiltermeans an unauthenticated request still has anAuthenticationobject. Code that checksauthentication != nullas a proxy for "logged in" is therefore always true. Check the authorities, orisAuthenticated()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() 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.
getHandler(request)
RequestMappingHandlerMapping matches method, path, params, headers
and content type against the registered @RequestMapping metadata,
returning a HandlerExecutionChain.
applyPreHandle()
Runs each HandlerInterceptor. These are Spring's own interceptors —
not servlet filters, not the security chain. Returning false ends the
request here.
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.
@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.
If method security is enabled, the AOP proxy evaluates
@PreAuthorize at this point — after step 03 has already run.
HttpMessageConverter serialises the result. For JSON this is
Jackson, which writes every readable property unless told
otherwise.
05Argument resolvers and WebDataBinder
| Annotation | Resolver | Mechanism |
|---|---|---|
| @RequestParam | RequestParamMethodArgumentResolver | single value + type conversion |
| @PathVariable | PathVariableMethodArgumentResolver | from the URI template |
| @RequestHeader | RequestHeaderMethodArgumentResolver | header value |
| @RequestBody | RequestResponseBodyMethodProcessor | Jackson readValue |
| @ModelAttribute | ModelAttributeMethodProcessor | WebDataBinder 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.
@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.
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/
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.
A service method carries
@PreAuthorize("hasRole('ADMIN')")and is not enforced. What is the first thing to check?Whether@EnableMethodSecurityis 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.requestMatchers("/**").permitAll()appears aboverequestMatchers("/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-defaultanyRequest().authenticated(). The same first-match rule applies between multipleSecurityFilterChainbeans, ordered by@Order.Does
SecurityContextHolder.getContext().getAuthentication() != nullmean the user is logged in?No — it is always true.AnonymousAuthenticationFiltergives unauthenticated requests anAnonymousAuthenticationToken, so the null check never fails. Check the authorities, or that the authentication is not an anonymous one.A public method calls
this.doUpdate(...), wheredoUpdateis 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.