Opening Hook
Most Spring Boot applications stop at URL-level security: controllers protected by @EnableWebSecurity and path matchers. But what happens when your business logic spans multiple service classes, internal method calls, or when authorization rules depend on resource ownership rather than just roles? Method security with @PreAuthorize in Java & Spring Boot moves authorization from the perimeter into the heart of your application, letting you declare rules exactly where they matter most.
Why Method-Level Security Matters
URL-level security is coarse-grained. It protects endpoints, but once a request enters your service layer, anything goes. Consider a scenario: a team member calls an internal service method directly from a scheduled job, or a developer accidentally exposes a business operation through a new endpoint they forgot to protect. Method security catches these gaps by enforcing rules at the point of execution, regardless of how the method was invoked.
In production systems handling e-commerce orders, ride-hailing trips, or trading operations, this precision matters. You need to verify that a user can only cancel their own orders, or that a driver cannot reassign trips they don't own. Method-level rules codify these constraints in one place, making them visible and testable.
Enable Method Security with @EnableMethodSecurity
To activate method-level authorization, you must explicitly enable it on a Spring configuration class. This is different from web security configuration; it activates Spring's AOP-based authorization proxy layer.
package com.example.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
// No beans needed; the annotation does the work
}
The prePostEnabled = true flag enables both @PreAuthorize and @PostAuthorize annotations. If you only need @PreAuthorize, you can leave it enabled; there's minimal overhead. Some teams also enable securedEnabled = true to support the older @Secured annotation, but @PreAuthorize is more flexible and is the modern choice.
Once enabled, any method annotated with @PreAuthorize will be wrapped in a security check. If the check fails, Spring throws an AccessDeniedException, which your exception handler can convert to a 403 response.
Role Checks: Keeping Rules Readable
The simplest use case is role-based access. If you have a small number of roles and the rule is straightforward, keep it inline and readable.
package com.example.service;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(long id) {
// Only ADMIN role can execute this
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
userRepository.delete(user);
}
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
public void promoteEmployee(long employeeId) {
// MANAGER or ADMIN can promote
Employee emp = employeeRepository.findById(employeeId)
.orElseThrow(() -> new EmployeeNotFoundException(employeeId));
emp.setRank("SENIOR");
employeeRepository.save(emp);
}
@PreAuthorize("hasRole('USER')")
public void updateProfile(long userId, UserUpdateRequest req) {
// Authenticated users with USER role
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
user.setName(req.getName());
userRepository.save(user);
}
}
The expression language is intuitive: hasRole() checks if the current user has a specific role, hasAnyRole() matches multiple roles. Spring automatically prepends ROLE_ to the role name, so hasRole('ADMIN') matches a granted authority of ROLE_ADMIN.
For global checks like "must be authenticated," use @PreAuthorize("isAuthenticated()"). For open methods, omit the annotation entirely or use @PreAuthorize("permitAll()").
Ownership Checks: Resource-Level Authorization
Role checks alone are insufficient for data-driven security. In a multi-tenant SaaS or marketplace, you must verify that a user owns or has permission to access a specific resource. This is where delegating to a custom bean shines.
Define a bean that encapsulates your authorization logic:
package com.example.security;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
@Component("orders")
public class OrderAuthorizationService {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
public OrderAuthorizationService(OrderRepository orderRepository,
UserRepository userRepository) {
this.orderRepository = orderRepository;
this.userRepository = userRepository;
}
public boolean isOwner(long orderId, Authentication auth) {
if (auth == null || !auth.isAuthenticated()) {
return false;
}
String username = auth.getName();
Order order = orderRepository.findById(orderId)
.orElse(null);
if (order == null) {
return false;
}
return order.getCustomerId().equals(username);
}
public boolean isOwnerOrAdmin(long orderId, Authentication auth) {
if (auth == null || !auth.isAuthenticated()) {
return false;
}
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(ga -> ga.getAuthority().equals("ROLE_ADMIN"));
if (isAdmin) {
return true;
}
return isOwner(orderId, auth);
}
}
Now use this bean in your service methods via the @PreAuthorize SpEL expression:
package com.example.service;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@PreAuthorize("@orders.isOwner(#id, authentication)")
public Order getOrderDetails(long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@PreAuthorize("@orders.isOwnerOrAdmin(#id, authentication)")
public void cancelOrder(long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.setStatus("CANCELLED");
orderRepository.save(order);
}
@PreAuthorize("@orders.isOwner(#id, authentication)")
public void updateShippingAddress(long id, Address newAddress) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.setShippingAddress(newAddress);
orderRepository.save(order);
}
}
The #id syntax captures method parameters; authentication is the current Authentication object. The bean name in @ notation refers to the component name (in this example, @orders). This pattern is powerful in ride-hailing, travel, and e-commerce platforms where every resource has an owner or tenant.
Testing Method Security with @WithMockUser
Unit and integration tests must verify that your authorization rules work. Spring Security provides @WithMockUser to inject a mock authenticated user during test execution.
package com.example.service;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.ActiveProfiles;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@SpringBootTest
@ActiveProfiles("test")
public class UserServiceSecurityTest {
@Autowired
private UserService userService;
@MockBean
private UserRepository userRepository;
@Test
@WithMockUser(roles = "USER")
public void deleteUser_withUserRole_shouldThrowAccessDenied() {
assertThrows(AccessDeniedException.class, () -> {
userService.deleteUser(1L);
});
}
@Test
@WithMockUser(roles = "ADMIN")
public void deleteUser_withAdminRole_shouldSucceed() {
User mockUser = new User();
mockUser.setId(1L);
mockUser.setName("John");
when(userRepository.findById(1L)).thenReturn(java.util.Optional.of(mockUser));
assertDoesNotThrow(() -> {
userService.deleteUser(1L);
});
verify(userRepository).delete(mockUser);
}
@Test
public void deleteUser_notAuthenticated_shouldThrowAccessDenied() {
assertThrows(AccessDeniedException.class, () -> {
userService.deleteUser(1L);
});
}
}
For resource ownership tests, you can mock the authorization bean:
@SpringBootTest
@ActiveProfiles("test")
public class OrderServiceSecurityTest {
@Autowired
private OrderService orderService;
@MockBean
private OrderRepository orderRepository;
@MockBean
private OrderAuthorizationService orderAuthService;
@Test
@WithMockUser(username = "customer1")
public void cancelOrder_ownerCancelsOwn_shouldSucceed() {
Order mockOrder = new Order();
mockOrder.setId(5L);
mockOrder.setCustomerId("customer1");
mockOrder.setStatus("PENDING");
when(orderRepository.findById(5L)).thenReturn(java.util.Optional.of(mockOrder));
when(orderAuthService.isOwnerOrAdmin(5L, any())).thenReturn(true);
assertDoesNotThrow(() -> {
orderService.cancelOrder(5L);
});
}
@Test
@WithMockUser(username = "customer2")
public void cancelOrder_nonOwnerAttempts_shouldThrowAccessDenied() {
when(orderAuthService.isOwnerOrAdmin(5L, any())).thenReturn(false);
assertThrows(AccessDeniedException.class, () -> {
orderService.cancelOrder(5L);
});
}
}
Each test method is isolated and clear: role-based tests verify that the right roles are allowed, and ownership tests confirm that users cannot access others' resources.
Common Mistakes
- Forgetting @EnableMethodSecurity: Without it, all
@PreAuthorizeannotations are silently ignored. The method executes regardless. Always include the configuration class in your application context. - Mixing authorization levels: Applying
@PreAuthorizeto every method clutters code and makes it hard to maintain. Use URL-level security for coarse concerns (e.g., /admin/* requires ADMIN role) and method-level security for fine-grained rules (e.g., user can only read their own profile). - Expensive authorization checks: If your
@PreAuthorizeexpression calls a database query on every invocation, you'll kill performance. Cache results or preload permissions during authentication. - Testing without proper mocking: Integration tests for method security must either mock the authorization bean or set up a real Spring context with test data. Skipping this leaves authorization bugs in production.
- Assuming SpEL injection safety: Do not construct SpEL expressions from user input. Always use method parameters (#id, #username) and trusted beans. Dynamic expressions are a security vulnerability.
Key Takeaways
- Enable method security with
@EnableMethodSecurity(prePostEnabled = true)on a configuration class to activate AOP-based authorization checks. - Use
@PreAuthorize("hasRole('ROLE_NAME')")for simple, role-based rules that are easy to read and maintain. - Delegate complex ownership and resource-level checks to a custom authorization bean (e.g.,
@orders.isOwner(#id, authentication)) to keep methods lean and authorization logic testable. - Test every authorization rule with
@WithMockUserin both the positive case (access granted) and negative case (access denied) to catch gaps early. - Reserve method-level security for business logic rules; use URL-level security for perimeter defense. Both layers together provide defense in depth.
- Avoid expensive authorization checks in
@PreAuthorizeexpressions; cache permissions or preload them during authentication to maintain performance.






Comments
Loading comments…