Spring Security Method-Level Authorization — @PreAuthorize, SpEL, and Custom Permission Evaluators
by Eric Hanson, Backend Developer at Clean Systems Consulting
Why URL-level authorization isn't enough
URL-level authorization (requestMatchers("/orders/**").authenticated()) answers "can this user access this URL path?" It can't answer "can this user access order 123?" — because whether a user can access an order depends on whether they own it, not just whether they're authenticated.
Method-level authorization brings the access decision into the application layer where the data exists. @PreAuthorize("@orderSecurity.canAccess(#orderId, authentication)") calls a Spring bean that can query the database, check ownership, verify team membership, or apply any business-level access rule.
Enable method security:
@Configuration
@EnableMethodSecurity // Spring Security 6 (replaces @EnableGlobalMethodSecurity)
public class MethodSecurityConfig {
// No additional beans required for basic @PreAuthorize
}
@PreAuthorize — expressions before method execution
@PreAuthorize evaluates a SpEL expression before the method runs. If the expression returns false, Spring throws AccessDeniedException without executing the method:
@Service
public class OrderService {
// Simple role check — equivalent to URL-level
@PreAuthorize("hasRole('ADMIN')")
public List<Order> findAllOrders() {
return orderRepository.findAll();
}
// Multiple roles
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public Order findOrderForSupport(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
// Method parameter reference with #
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public List<Order> findOrdersForUser(Long userId) {
return orderRepository.findByUserId(userId);
}
// Complex expression with bean reference
@PreAuthorize("@orderSecurity.canModify(#orderId, authentication)")
public Order updateOrder(Long orderId, UpdateOrderRequest request) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.update(request);
return orderRepository.save(order);
}
}
SpEL variables available in @PreAuthorize:
authentication— the currentAuthenticationobjectprincipal—authentication.getPrincipal(), typicallyUserDetails#parameterName— references a method parameter by name@beanName— calls a Spring bean method
The #parameterName resolution. Spring needs to know parameter names at runtime. With Java 8+ compiled with -parameters flag, or with Spring's LocalVariableTableParameterNameDiscoverer, parameter names are available. Spring Boot configures this automatically. If parameter name resolution fails, use @Param or SpEL index syntax:
// Explicit parameter reference if name resolution fails
@PreAuthorize("#userId == authentication.principal.id")
public Order findOrder(@P("userId") Long userId, Long orderId) {
// ...
}
@PostAuthorize — expressions after method execution
@PostAuthorize evaluates after the method runs, with access to the return value via returnObject:
@PostAuthorize("returnObject.userId == authentication.principal.id or hasRole('ADMIN')")
public Order findOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
// Method executes first — then authorization is checked
// If unauthorized, AccessDeniedException is thrown AFTER the query ran
}
The method executes regardless of authorization — the data is loaded before the check. Use @PostAuthorize only when:
- The authorization decision depends on the resource's data (you need to load the resource to check ownership)
- Loading the resource is cheap enough to discard if unauthorized
- The operation has no side effects (a read is discarded; a write that's then unauthorized is a problem)
For write operations, @PreAuthorize with a preliminary check is safer:
// Better for writes — checks before executing
@PreAuthorize("@orderSecurity.isOwner(#orderId, authentication)")
public void deleteOrder(Long orderId) {
orderRepository.deleteById(orderId);
}
@PostFilter and @PreFilter — filtering collections
@PostFilter removes elements from the returned collection that don't satisfy the expression:
@PostFilter("filterObject.userId == authentication.principal.id or hasRole('ADMIN')")
public List<Order> findOrders() {
return orderRepository.findAll();
// Loads ALL orders, then filters in Java to those the user can see
// Only appropriate for small collections — use @PreAuthorize + query filter for large ones
}
filterObject refers to each element in the returned collection. The expression is evaluated per element; elements returning false are removed.
@PreFilter filters the input collection before the method executes — rarely used but useful when a method accepts a list and should only process the elements the user is authorized to affect.
The performance trap. @PostFilter loads the entire result set then filters in Java. For large tables, this is a full table scan to return a few rows. Use it only for small, bounded collections. For large datasets, apply the filter at the query level:
// Wrong for large datasets — loads everything
@PostFilter("filterObject.userId == authentication.principal.id")
public List<Order> findOrders() {
return orderRepository.findAll(); // loads 10,000 orders to return 50
}
// Correct — filter in the database
@PreAuthorize("isAuthenticated()")
public List<Order> findOrders(Authentication auth) {
Long userId = ((AppUserDetails) auth.getPrincipal()).getId();
return orderRepository.findByUserId(userId); // loads only the user's orders
}
Custom PermissionEvaluator — domain object security
For complex, reusable permission logic, implement PermissionEvaluator. This enables the hasPermission() expression in SpEL:
@Component
public class OrderPermissionEvaluator implements PermissionEvaluator {
private final OrderRepository orderRepository;
private final TeamMembershipService teamService;
@Override
public boolean hasPermission(Authentication auth, Object targetDomainObject,
Object permission) {
if (targetDomainObject instanceof Order order) {
return hasPermission(auth, order, permission.toString());
}
return false;
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId,
String targetType, Object permission) {
if ("Order".equals(targetType)) {
Order order = orderRepository.findById((Long) targetId).orElse(null);
if (order == null) return false;
return hasPermission(auth, order, permission.toString());
}
return false;
}
private boolean hasPermission(Authentication auth, Order order, String permission) {
Long userId = ((AppUserDetails) auth.getPrincipal()).getId();
return switch (permission) {
case "READ" -> order.getUserId().equals(userId) ||
hasRole(auth, "ADMIN") ||
teamService.isMember(order.getTeamId(), userId);
case "WRITE" -> order.getUserId().equals(userId) ||
hasRole(auth, "ADMIN");
case "DELETE" -> hasRole(auth, "ADMIN");
default -> false;
};
}
private boolean hasRole(Authentication auth, String role) {
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_" + role));
}
}
Register it:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
OrderPermissionEvaluator permissionEvaluator) {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(permissionEvaluator);
return handler;
}
}
Usage in @PreAuthorize:
// Check by domain object
@PreAuthorize("hasPermission(#order, 'READ')")
public OrderResponse getOrderDetails(Order order) { ... }
// Check by ID and type (loads object internally via PermissionEvaluator)
@PreAuthorize("hasPermission(#orderId, 'Order', 'WRITE')")
public Order updateOrder(Long orderId, UpdateOrderRequest request) { ... }
// Check return value after method execution
@PostAuthorize("hasPermission(returnObject, 'READ')")
public Order findOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
The PermissionEvaluator centralizes all access logic for a resource type — the business rules for who can read, write, and delete an Order live in one class rather than scattered across multiple @PreAuthorize expressions.
Bean method expressions — for complex authorization
@beanName.method() in @PreAuthorize delegates to a Spring bean, enabling any complexity of authorization logic:
@Service("orderSecurity")
public class OrderSecurityService {
private final OrderRepository orderRepository;
private final ProjectMembershipRepository membershipRepository;
public boolean canAccess(Long orderId, Authentication auth) {
AppUserDetails user = (AppUserDetails) auth.getPrincipal();
return orderRepository.findById(orderId)
.map(order -> isOwner(order, user) || isTeamMember(order, user) ||
isAdmin(auth))
.orElse(false);
}
public boolean canModify(Long orderId, Authentication auth) {
AppUserDetails user = (AppUserDetails) auth.getPrincipal();
return orderRepository.findById(orderId)
.map(order -> isOwner(order, user) || isAdmin(auth))
.orElse(false);
}
private boolean isOwner(Order order, AppUserDetails user) {
return order.getUserId().equals(user.getId());
}
private boolean isTeamMember(Order order, AppUserDetails user) {
return order.getTeamId() != null &&
membershipRepository.existsByTeamIdAndUserId(order.getTeamId(), user.getId());
}
private boolean isAdmin(Authentication auth) {
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
}
}
This bean is unit testable — inject mocks, call the methods directly, assert on the result. Security logic that's buried in SpEL strings is harder to test and harder to read.
Testing method security
@WithMockUser sets the security context for test methods:
@SpringBootTest
class OrderServiceSecurityTest {
@Autowired OrderService orderService;
@MockBean OrderRepository orderRepository;
@Test
@WithMockUser(username = "alice", roles = "USER")
void findOrder_deniesAccess_whenNotOwner() {
Order order = new Order(999L, "alice", OrderStatus.PENDING);
// order.userId = 999L but authenticated user id = different user
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
assertThatThrownBy(() -> orderService.findOrder(1L))
.isInstanceOf(AccessDeniedException.class);
}
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void findOrder_grantsAccess_toAdmin() {
Order order = new Order(999L, "other-user", OrderStatus.PENDING);
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
assertThatNoException().isThrownBy(() -> orderService.findOrder(1L));
}
}
For custom UserDetails types, create a custom annotation:
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithAppUserFactory.class)
public @interface WithAppUser {
long id() default 1L;
String email() default "user@example.com";
String[] roles() default {"USER"};
}
public class WithAppUserFactory implements WithSecurityContextFactory<WithAppUser> {
@Override
public SecurityContext createSecurityContext(WithAppUser annotation) {
AppUserDetails userDetails = new AppUserDetails(
annotation.id(),
annotation.email(),
"password",
Arrays.stream(annotation.roles())
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.toList()
);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
return context;
}
}
// Usage
@Test
@WithAppUser(id = 42L, email = "alice@example.com", roles = "USER")
void findOrder_grantsAccess_toOwner() {
Order order = new Order(42L, "alice@example.com", OrderStatus.PENDING);
// order.userId = 42L, authenticated user id = 42L → access granted
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
assertThatNoException().isThrownBy(() -> orderService.findOrder(1L));
}
Custom @WithAppUser injects the AppUserDetails with the correct id field — enabling tests that verify ownership-based access decisions with the typed principal your service code expects.