Enabling Method Security
Why Secure Methods?
URL rules are good for broad areas (/api/admin/**). But some rules depend on data: "a customer may only see their own order". The URL /api/orders/42 looks the same for everyone. Method security puts the rule on the Java method itself, where you know the order id and the logged-in user.
| URL rules | Method security | |
|---|---|---|
| Where | SecurityFilterChain | Annotations on beans (controllers, services) |
| Best for | Whole areas of the API | Ownership and business rules |
| Can use method arguments | No | Yes (#orderId) |
| Can check return value | No | Yes (returnObject) |
Turning It On
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter
public class MethodSecurityConfig {
}@EnableMethodSecurityreplaces the old@EnableGlobalMethodSecurity(deprecated).prePostEnabledistrueby default. AddsecuredEnabled = truefor@Securedorjsr250Enabled = truefor@RolesAllowed.
How It Works: Proxies
Spring wraps your bean in a proxy. The caller talks to the proxy; the proxy checks the rule and only then calls your real method. If the rule fails it throws AccessDeniedException, which becomes 403.
Controller --> [ Proxy of OrderService ] --> real OrderService.cancel(42)
|
+-- check @PreAuthorize("hasRole('ADMIN')")
fail -> AccessDeniedException -> 403Example
A tiny version of the same idea with a JDK dynamic proxy and our own annotation:
import java.lang.annotation.*;
import java.lang.reflect.Proxy;
import java.util.Set;
public class Main {
static Set<String> currentUser = Set.of();
public static void main(String[] args) {
OrderService real = new OrderServiceImpl();
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(), new Class<?>[]{OrderService.class},
(p, method, a) -> {
RequiresRole rule = method.getAnnotation(RequiresRole.class);
if (rule != null && !currentUser.contains("ROLE_" + rule.value())) {
throw new SecurityException("Access Denied for " + method.getName());
}
return method.invoke(real, a);
});
currentUser = Set.of("ROLE_CUSTOMER");
System.out.println(proxy.track(42));
try {
proxy.cancel(42);
} catch (SecurityException e) {
System.out.println("403 -> " + e.getMessage());
}
currentUser = Set.of("ROLE_ADMIN");
System.out.println(proxy.cancel(42));
}
}
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface RequiresRole { String value(); }
interface OrderService {
String track(long id);
@RequiresRole("ADMIN") String cancel(long id);
}
class OrderServiceImpl implements OrderService {
public String track(long id) { return "Order " + id + " is OUT_FOR_DELIVERY"; }
public String cancel(long id) { return "Order " + id + " cancelled"; }
}Output
Order 42 is OUT_FOR_DELIVERY 403 -> Access Denied for cancel Order 42 cancelled
The Self-Invocation Trap
Note: Because the check lives in the proxy, calling a secured method from inside the same class (this.cancel(id)) skips the proxy and skips the check. Put secured methods in a separate bean, or secure the public entry method.