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 rulesMethod security
WhereSecurityFilterChainAnnotations on beans (controllers, services)
Best forWhole areas of the APIOwnership and business rules
Can use method argumentsNoYes (#orderId)
Can check return valueNoYes (returnObject)

Turning It On

import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity   // enables @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter
public class MethodSecurityConfig {
}
  • @EnableMethodSecurity replaces the old @EnableGlobalMethodSecurity (deprecated).
  • prePostEnabled is true by default. Add securedEnabled = true for @Secured or jsr250Enabled = true for @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 -> 403

Example

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.