
Spring Security 6 Migration: Method Security Guide
A practical guide for Java developers migrating to Spring Security 6, covering enableMethodSecurity, @PreAuthorize, AOP, and self-invocation pitfalls.
Spring Security 6 Method Security Migration
Migrating a Java application from Spring Security 5.x to 6.x introduces a significant breaking change in how method-level security is enforced. The legacy @EnableGlobalMethodSecurity annotation has been deprecated, replaced by @EnableMethodSecurity, which demands explicit opt-in and offers finer-grained control. This shift exposes developers to stricter Aspect-Oriented Programming (AOP) proxy behaviors, particularly regarding self-invocation within service beans.
This guide explains the underlying mechanisms of this migration, why internal method calls bypass security checks, and how to refactor your code to maintain security integrity without compromising architectural design.
The End of Global Defaults
In Spring Security 5, enabling method security was often a matter of adding @EnableGlobalMethodSecurity(prePostEnabled = true) to a configuration class. This annotation globally enabled the MethodSecurityInterceptor for all beans in the context. It was a blunt instrument: it applied to every bean, regardless of whether they needed security.
Spring Security 6 removes this global default. The rationale is explicit configuration over implicit behavior. You must now explicitly opt-in using @EnableMethodSecurity.
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
// Configuration details
}While @EnableMethodSecurity looks similar, it introduces new attributes that allow you to enable specific annotation types individually (prePostEnabled, jsr250Enabled, securedEnabled). By default, only prePostEnabled is true in the simplest configuration, but this behavior can be tuned. The key takeaway is that method security is no longer "on by default" in the same pervasive way. If you forget to add @EnableMethodSecurity, your @PreAuthorize annotations will be ignored entirely, not because they are malformed, but because the interceptor chain is not registered.
During a spring security 6 migration, developers often encounter confusion because the implicit behavior of previous versions no longer applies. For detailed guidance on these changes, refer to the official Spring Security documentation on method security.
The AOP Proxy Mechanism
To understand why migration can break existing code, you must understand how Spring Security enforces method-level restrictions. Spring Security does not modify your bytecode. Instead, it relies on Spring AOP to create proxies around your beans.
When you annotate a service method with @PreAuthorize("hasRole('ADMIN')"), Spring creates a proxy object that wraps the actual service instance. When an external component calls a method on this proxy, the call is intercepted by a MethodSecurityInterceptor. This interceptor checks the current SecurityContext before delegating the call to the actual business logic.
This mechanism works seamlessly for external calls. However, it introduces a critical limitation known as the self-invocation problem.
The Self-Invocation Pitfall
Consider a scenario where a service method calls another method within the same class. This is common in domain services where one business operation depends on another.
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN')")
public void createOrder(Order order) {
// ... validation ...
updateInventory(order); // Internal call
}
public void updateInventory(Order order) {
// ... logic ...
}
}If createOrder is called by a controller, the proxy intercepts the call, checks the ADMIN role, and proceeds. Inside createOrder, the code calls updateInventory. Because this is a call to this.updateInventory(), it bypasses the proxy entirely. The call happens within the same object instance.
The updateInventory method has no security annotation in this example, but even if it did, the check would not run. The proxy never sees the internal call. This is not a bug; it is a fundamental property of Java proxies. The proxy only intercepts methods called on the proxy reference, not methods called on the target instance (this).
This introduces a critical limitation known as the aop self invocation problem. In Spring Security 5, this behavior was consistent. However, developers often relied on implicit assumptions about how proxies worked. With the explicit nature of Spring Security 6, the separation between the security context and the proxy mechanism becomes more apparent. If you rely on internal method calls to enforce security, your migration to Spring Security 6 will expose these latent vulnerabilities.
Solutions and Tradeoffs
There are two primary ways to handle self-invocation in Spring Security 6.
1. Refactoring to Separate Beans (Recommended)
The most robust solution is to refactor your code so that security-checked methods are in separate beans. This ensures that the call goes through the proxy.
@Service
public class OrderService {
private final InventoryService inventoryService;
public OrderService(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@PreAuthorize("hasRole('ADMIN')")
public void createOrder(Order order) {
// ... validation ...
inventoryService.updateInventory(order); // External call, goes through proxy
}
}
@Service
public class InventoryService {
@PreAuthorize("hasRole('ADMIN')")
public void updateInventory(Order order) {
// ... logic ...
}
}By injecting InventoryService into OrderService, the call to updateInventory is made on the proxy instance, not this. The security interceptor is triggered, and the check is performed. This approach aligns with clean architecture principles, separating concerns and making security policies explicit. In a typical spring boot application, dependency injection makes this refactoring straightforward and maintains clear separation of concerns.
2. Using AopContext (Not Recommended)
Spring provides AopContext.currentProxy() to retrieve the current proxy from the thread-local context. You can cast this to your service type and call the method.
@PreAuthorize("hasRole('ADMIN')")
public void createOrder(Order order) {
((OrderService) AopContext.currentProxy()).updateInventory(order);
}For this to work, you must enable expose-proxy in your configuration:
@Configuration
@EnableMethodSecurity
@EnableAspectJAutoProxy(exposeProxy = true)
public class SecurityConfig {
// ...
}While this technically works, it introduces tight coupling to Spring AOP. Your business logic now depends on framework-specific classes, making unit testing harder and reducing portability. It is generally considered an anti-pattern unless refactoring is impossible due to legacy constraints.
Conclusion
Migrating java method security in Spring Security 6 requires more than just adding @EnableMethodSecurity. It demands a review of how your application enforces security at the method level. The removal of global defaults and the strict adherence to AOP proxy mechanics mean that self-invocation will no longer silently fail security checks.
By refactoring your code to use separate beans for security-checked operations, you not only ensure compatibility with Spring Security 6 but also improve the overall modularity and testability of your application. The cost of migration is low if you treat it as an opportunity to clean up architectural debt rather than a mere version bump.
Related posts
Spring Security SAML: A Working Integration
A practical guide to integrating Spring Security SAML with Spring Boot, covering SAML2 relying party configuration, service provider setup, and metadata handling.
Spring Security SSO With Group and Role Mapping
Implement Spring Security SSO with group and role mapping using GrantedAuthoritiesMapper and OIDC claims for secure access control.
ExceptionTranslationFilter: Auth Error Handling
Explore how Spring Security's ExceptionTranslationFilter handles authentication and authorization errors, routing them to the correct entry points or access denied handlers.