Spring Security is powerful enough that most Spring Boot security bugs are not missing features. They are configuration that says something different from what the team believes: an annotation that is never enforced, a matcher that grew wider than intended, a management endpoint exposed for one debugging session and never closed.
This post covers five mistakes we see in Spring Boot applications, each with the vulnerable shape and the fix. All of them are easy to write and hard to see in a diff, because the code that is wrong looks exactly like the code that is right.
1. @PreAuthorize that nothing enforces
@PreAuthorize("hasRole('ADMIN')") on a service method reads like a guarantee. It is only an instruction to a proxy, and the proxy exists only if method security is switched on. The Spring Security reference is explicit that the Boot starter "does not activate method-level authorization by default".
// Vulnerable: no @EnableMethodSecurity anywhere, so this check never runs
@Service
public class AccountService {
@PreAuthorize("hasRole('ADMIN')")
public void closeAccount(long id) { ... }
}// Fixed
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig { }@EnableMethodSecurity turns on @PreAuthorize, @PostAuthorize, @PreFilter and @PostFilter; @Secured and JSR-250 annotations need securedEnabled or jsr250Enabled. The older @EnableGlobalMethodSecurity is deprecated.
Two more ways the annotation goes quiet:
- Self-invocation. Method security is applied through a Spring proxy in the default mode, so
this.closeAccount(id)from another method in the same class skips the check. Put the guarded method on a separate bean, or guard the public entry point. - The wrong method. An annotation on a private helper, which the proxy never intercepts, protects nothing. Test it: call the method as a non-admin and assert
AccessDeniedException.
2. permitAll drift in the SecurityFilterChain
The HttpSecurity rules are checked in order and the first match wins. Over time a chain like this one accumulates exceptions:
// Vulnerable: each line was added for a reason; together they open /api/**
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/**").permitAll() // "temporary", for a demo
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated());The /api/** line makes every API route public, including anything under /api/admin/, because the first matching rule wins and nothing below it is consulted. Another common drift is an anyRequest().permitAll() at the end, which turns every route nobody listed into a public one.
// Fixed: narrow public matchers, deny by default
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/actuator/health").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated());Keep the public list short and specific, put the most specific matchers first, and end with authenticated() or denyAll(). Then review the chain and the controller annotations together: a route that is permitAll in the chain and has no method-level check is public, whatever the class name suggests.
3. Actuator endpoints exposed to the internet
Spring Boot's default is careful: according to the Actuator documentation, only the health endpoint is exposed over HTTP. The risk comes from one line copied from a tutorial:
# Vulnerable: exposes every endpoint over HTTP
management.endpoints.web.exposure.include=*With that, /actuator/env lists configuration properties (values are masked by default in current versions), /actuator/loggers lets a caller change log levels, and /actuator/heapdump can return a heap dump of the JVM, which holds whatever secrets, tokens and session data were in memory. Current Spring Boot versions keep heapdump and shutdown access restricted unless you open it, but older applications and copied management.endpoint.* settings often have it open. The documentation's advice is to make sure exposed actuators do not contain sensitive information, or sit behind a firewall, or are secured with something like Spring Security.
# Fixed: expose only what operations needs
management.endpoints.web.exposure.include=health,info,prometheus
management.server.port=9090A separate management port that is not routed publicly, plus a SecurityFilterChain that requires a role for EndpointRequest.toAnyEndpoint(), gives you two independent layers.
4. Mass assignment through @ModelAttribute
Spring MVC binds request parameters onto a command object by property name. If the object is your JPA entity, the request can set any property it names:
// Vulnerable: ?role=ADMIN&verified=true is bound too
@PostMapping("/profile")
public String update(@ModelAttribute User user) {
userRepository.save(user);
return "redirect:/profile";
}The fix is to bind a small DTO that holds only the fields the form owns, or to set an allowlist:
@InitBinder
void initBinder(WebDataBinder binder) {
binder.setAllowedFields("displayName", "avatarUrl");
}The same mechanism is what Spring4Shell (CVE-2022-22965) abused to reach the class loader through nested properties, which is why an allowlist is worth having even after patching. Our rule page on Spring data binding shows both shapes. For JSON bodies, @RequestBody with an entity has the same object-property problem through Jackson, so the DTO advice applies there too.
5. SpEL injection
Spring Expression Language is a full expression language: in its standard form it can reference types and call constructors. Evaluating a user string with it is code execution.
// Vulnerable
ExpressionParser parser = new SpelExpressionParser();
Object v = parser.parseExpression(request.getParameter("filter"))
.getValue(new StandardEvaluationContext(order));The Spring Framework docs say StandardEvaluationContext "must never be used to evaluate an expression obtained from an untrusted source". SimpleEvaluationContext drops type references, constructors and bean references, but the same page notes that its restriction is best effort and does not guarantee safety. The real fix is not to evaluate user input as an expression at all: accept a field name and an operator from a fixed list and build the filter in Java.
This is not theoretical. In CVE-2022-22963, Spring Cloud Function's routing feature evaluated a SpEL expression supplied in a request, which allowed remote code execution on affected versions.
Why these become attack paths
Each mistake is minor alone. Together they chain: a route that drifted to permitAll, calling a service whose @PreAuthorize never runs, that saves an entity bound from the request. The question that matters is not "is there a @PreAuthorize somewhere" but "can an unauthenticated request reach this write". That is a path through the code, from the route to the sink, with the checks along the way.
What Vulkro checks in Spring Boot
Vulkro Core maps Spring MVC, WebFlux and JAX-RS endpoints and reads the access rules on each one: @PreAuthorize, @PostAuthorize, @Secured, @RolesAllowed, @PermitAll, @DenyAll, SpEL role expressions and the HttpSecurity chain (requestMatchers, antMatchers, mvcMatchers, anyRequest). Request data is followed across files from controller to service to repository. Relevant rules include JAVA-ACTUATOR-001 (management endpoints exposed to unauthenticated callers), JAVA-SPEL-001 (a request value reaching a SpEL parser), SPRING-DATABIND-001 (a @ModelAttribute bind with no field allowlist) and JAVA-MASSASSIGN-001. The attack-path graph shows each route that reaches a risky operation, hop by hop, and the Java and Spring page lists all 30 Java detectors.
FAQ
Why is @PreAuthorize not working in Spring Boot?
The usual cause is that method security is not enabled: add @EnableMethodSecurity to a configuration class. The other common causes are self-invocation (calling the method from inside the same bean, which bypasses the proxy) and annotating a method that is not public or not called through the bean.
Is it safe to expose Spring Boot Actuator endpoints?
health is exposed by default and is generally fine. Exposing everything with include=* publishes endpoints such as env, loggers and, where its access is open, heapdump. Expose only what you need, preferably on a separate management port, and require a role for the rest.
How do I prevent mass assignment in Spring MVC?
Bind requests to a purpose-built DTO with only the writable fields, or call setAllowedFields in an @InitBinder method. Never bind directly onto a JPA entity that has fields like role or verified.
Check your own Spring services: Start a 14-day trial. Related: attack paths, not findings lists and the OWASP API Security Top 10 from source code.