Spring Security Method-Level Security: @PreAuthorize, RBAC & Multi-Tenancy Patterns
URL-level security in Spring Security — locking down /api/admin/** to admins — is the entry-level skill. The difference between junior and senior Spring engineers is method-level security: @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter, role hierarchies, and custom SpEL beans. This guide covers all of them, plus multi-tenant patterns and performance considerations.
Software Engineer · Java · Spring Boot · Spring Security
Enabling Method-Level Security
Method security is disabled by default. Enable it with a single annotation on your @Configuration class:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(
prePostEnabled = true, // @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter
securedEnabled = true, // @Secured (legacy, avoid in new code)
jsr250Enabled = true // @RolesAllowed (JSR-250, optional)
)
public class SecurityConfig {
// ...
}
// Spring Security 6 note:
// @EnableMethodSecurity replaces the deprecated @EnableGlobalMethodSecurity
// prePostEnabled = true is the default when using @EnableMethodSecurity
Method security uses Spring AOP under the hood — a proxy wraps your bean and intercepts calls. The security check runs before or after the method depending on the annotation used.
@PreAuthorize: The Workhorse of Method Security
@PreAuthorize evaluates a Spring Expression Language (SpEL) expression before the method body executes. If the expression returns false, a AccessDeniedException is thrown.
Basic Role and Authority Checks
@Service
public class OrderService {
// Simple role check
@PreAuthorize("hasRole('ADMIN')")
public List<Order> findAll() { /* ... */ }
// Multiple roles — OR logic
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
public Page<Order> findAllPaged(Pageable pageable) { /* ... */ }
// Authority check (fine-grained permission string)
@PreAuthorize("hasAuthority('order:write')")
public Order create(CreateOrderDto dto) { /* ... */ }
// Multiple authorities — AND logic with &&
@PreAuthorize("hasAuthority('order:read') and hasAuthority('order:export')")
public byte[] exportCsv() { /* ... */ }
// Authenticated vs anonymous
@PreAuthorize("isAuthenticated()")
public Order findById(String id) { /* ... */ }
@PreAuthorize("isAnonymous()")
public String publicEndpoint() { /* ... */ }
}
SpEL Parameter Binding
The most powerful feature of @PreAuthorize is binding to method parameters via #paramName syntax, enabling ownership and contextual checks:
@Service
public class OrderService {
// Check that the authenticated user owns the resource
@PreAuthorize("#userId == authentication.name")
public List<Order> findByUser(String userId) { /* ... */ }
// Check ownership via a method parameter object's field
@PreAuthorize("#dto.ownerId == authentication.name")
public Order update(UpdateOrderDto dto) { /* ... */ }
// Combine role check with ownership
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
public void deleteUserOrders(String userId) { /* ... */ }
// Parameter binding works with nested paths too
@PreAuthorize("#order.customer.id == authentication.name")
public void processOrder(Order order) { /* ... */ }
}
Important: Parameter binding with #paramName requires that your code is compiled with the -parameters flag, or that you use Spring Boot (which sets this by default). Without it, parameter names are lost at runtime and you must use #a0, #a1 etc. (positional arguments).
@PostAuthorize: Filter by Return Value
@PostAuthorize runs after the method completes and can access the return value via returnObject. Use it when you cannot determine authorization until the method has run — typically for ownership checks where the resource is loaded from the database:
@Service
public class DocumentService {
// Method runs first, then Spring checks if caller owns the returned document
@PostAuthorize("returnObject.ownerId == authentication.name or hasRole('ADMIN')")
public Document findById(String id) {
return documentRepo.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
}
// Works with Optional — note the null safety
@PostAuthorize("returnObject.isEmpty() or returnObject.get().ownerId == authentication.name")
public Optional<Document> findByExternalId(String externalId) {
return documentRepo.findByExternalId(externalId);
}
// Tenant isolation — department check from the returned object
@PostAuthorize("hasRole('ADMIN') or returnObject.department == authentication.principal.department")
public Report getReport(String reportId) {
return reportRepo.findById(reportId).orElseThrow();
}
}
Performance note: @PostAuthorize executes the method and then throws if authorization fails. For expensive operations, prefer @PreAuthorize with a custom security bean that does a lightweight DB check (exists by id + owner) rather than loading the full object.
@PreFilter and @PostFilter: Filtering Collections
@Service
public class DocumentService {
// @PreFilter — filter the INPUT collection before the method executes
// 'filterObject' refers to each element of the collection parameter
@PreFilter("filterObject.ownerId == authentication.name")
public List<Document> bulkUpdate(List<Document> documents) {
// Only documents owned by the caller reach this method
return documentRepo.saveAll(documents);
}
// @PostFilter — filter the RETURNED collection
// 'filterObject' refers to each element of the returned collection
@PostFilter("filterObject.tenantId == authentication.principal.tenantId")
public List<Document> findAll() {
return documentRepo.findAll(); // Full list; Spring filters it post-execution
}
// Combine @PreAuthorize and @PostFilter
@PreAuthorize("isAuthenticated()")
@PostFilter("filterObject.ownerId == authentication.name or hasRole('ADMIN')")
public List<Document> search(String query) {
return documentRepo.findByQuery(query);
}
}
Performance caution: @PostFilter iterates through the entire returned list to check each element's SpEL expression. For large result sets this is expensive. Prefer pushing the ownership/tenant filter into the database query itself, and use @PostFilter only as a secondary safety net.
RBAC Design: Role Hierarchy
By default, Spring Security roles are flat. A user with ROLE_ADMIN does not automatically have ROLE_USER permissions. RoleHierarchyImpl fixes this by declaring role inheritance:
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_SUPER_ADMIN > ROLE_ADMIN
ROLE_ADMIN > ROLE_MANAGER
ROLE_MANAGER > ROLE_TEAM_LEAD
ROLE_TEAM_LEAD > ROLE_EMPLOYEE
ROLE_EMPLOYEE > ROLE_VIEWER
""");
}
// With this hierarchy:
// A user with ROLE_ADMIN automatically has ROLE_MANAGER, ROLE_TEAM_LEAD,
// ROLE_EMPLOYEE, and ROLE_VIEWER permissions.
// @PreAuthorize("hasRole('EMPLOYEE')") passes for ADMIN users too.
// Must also configure it in the method security expression handler:
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
return handler;
}
Custom SpEL Beans: Beyond Built-in Expressions
For complex authorization logic that cannot be expressed cleanly in SpEL strings, create a security bean and call it from SpEL using the @beanName.method() syntax:
// OrderSecurityService.java — custom security bean
@Service("orderSecurity") // bean name used in SpEL
public class OrderSecurityService {
private final OrderRepository orderRepo;
private final TenantService tenantService;
// Is the authenticated user the owner of this order?
public boolean isOwner(String orderId, Authentication auth) {
String userId = auth.getName();
return orderRepo.existsByIdAndUserId(orderId, userId);
}
// Is the user in the same department as the order?
public boolean isSameDepartment(String orderId, Authentication auth) {
UserDetails user = (UserDetails) auth.getPrincipal();
String userDept = ((CustomUserDetails) user).getDepartment();
return orderRepo.findById(orderId)
.map(order -> order.getDepartment().equals(userDept))
.orElse(false);
}
// Multi-tenant membership check
public boolean isTenantMember(String tenantId, Authentication auth) {
String userId = auth.getName();
return tenantService.isMember(tenantId, userId);
}
}
// Usage in controllers/services:
@Service
public class OrderService {
@PreAuthorize("@orderSecurity.isOwner(#orderId, authentication)")
public Order getOrder(String orderId) { /* ... */ }
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isSameDepartment(#orderId, authentication)")
public void approveOrder(String orderId) { /* ... */ }
@PreAuthorize("@orderSecurity.isTenantMember(#tenantId, authentication)")
public List<Order> getTenantOrders(String tenantId) { /* ... */ }
}
Multi-Tenancy Authorization Patterns
Multi-tenant SaaS applications need to ensure that users can only access data belonging to their tenant. There are three main patterns:
Pattern 1: SpEL with JWT tenant claim
// JWT contains: { "sub": "user-123", "tenant_id": "acme-corp" }
// In a JwtAuthenticationConverter, promote tenant_id to a principal attribute:
@Component
public class CustomJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
String tenantId = jwt.getClaim("tenant_id");
String userId = jwt.getSubject();
// Build a custom UserDetails or use attributes map
Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
CustomJwtAuthenticationToken auth = new CustomJwtAuthenticationToken(jwt, authorities);
auth.setTenantId(tenantId);
return auth;
}
}
// Then in SpEL (authentication is the CustomJwtAuthenticationToken):
@PreAuthorize("#tenantId == authentication.tenantId")
public List<Order> getTenantOrders(String tenantId) { /* ... */ }
@PreAuthorize("@orderSecurity.isTenantMember(#orderId, authentication.tenantId)")
public Order getOrder(String orderId) { /* ... */ }
Pattern 2: Tenant filter at the data access layer
// TenantAwareRepository.java — automatically filters by tenant in every query
@Repository
public interface OrderRepository extends JpaRepository<Order, String> {
// Every method scoped to a tenant — no method-level check needed for basic queries
List<Order> findAllByTenantId(String tenantId);
Optional<Order> findByIdAndTenantId(String id, String tenantId);
}
// OrderService.java — get tenant from security context
@Service
public class OrderService {
@PreAuthorize("isAuthenticated()") // only ensure authenticated; tenant is enforced by repo
public List<Order> getOrders() {
String tenantId = SecurityContextHolder.getContext()
.getAuthentication().getDetails() instanceof TenantDetails td
? td.getTenantId()
: null;
return orderRepo.findAllByTenantId(tenantId);
}
}
Pattern 3: Hibernate multi-tenancy with @TenantId (Hibernate 6)
// Order.java — Hibernate 6 @TenantId
@Entity
@Table(name = "orders")
public class Order {
@Id
private String id;
@TenantId // Hibernate automatically adds WHERE tenant_id = ? to every query
private String tenantId;
// other fields...
}
// When Hibernate 6 @TenantId is in use, tenant isolation is enforced at the ORM level.
// Method-level @PreAuthorize is still used for role/ownership checks on top of this.
Testing Method Security
@SpringBootTest
@AutoConfigureMockMvc
class OrderServiceSecurityTest {
@Autowired
private OrderService orderService;
@Test
@WithMockUser(roles = "USER")
void findAll_withUserRole_throwsAccessDenied() {
assertThrows(AccessDeniedException.class, () -> orderService.findAll());
}
@Test
@WithMockUser(roles = "ADMIN")
void findAll_withAdminRole_succeeds() {
assertDoesNotThrow(() -> orderService.findAll());
}
@Test
@WithMockUser(username = "alice", roles = "USER")
void getOrder_ownedByAlice_succeedsForAlice() {
// assuming the order's ownerId is "alice"
assertDoesNotThrow(() -> orderService.getOrder("order-alice-1"));
}
@Test
@WithMockUser(username = "bob", roles = "USER")
void getOrder_ownedByAlice_failsForBob() {
assertThrows(AccessDeniedException.class, () -> orderService.getOrder("order-alice-1"));
}
// Custom principal with tenant
@Test
void getOrder_wrongTenant_throwsAccessDenied() {
Authentication auth = new UsernamePasswordAuthenticationToken(
"user", "pass",
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
// attach wrong tenant
SecurityContextHolder.getContext().setAuthentication(auth);
assertThrows(AccessDeniedException.class, () -> orderService.getTenantOrders("other-tenant"));
}
}
Common @PreAuthorize Pitfalls
| Pitfall | Cause | Fix |
|---|---|---|
| Self-invocation bypasses security | Calling a secured method from within the same bean skips the AOP proxy | Move the secured method to a different bean, or inject the proxy via @Autowired into self |
| #param binding fails | Parameter names compiled away | Ensure -parameters compiler flag (Spring Boot default) or use @Param |
| hasRole vs hasAuthority confusion | hasRole prepends ROLE_; hasAuthority is exact | Use hasRole('ADMIN') when GrantedAuthority is ROLE_ADMIN |
| RoleHierarchy not in SpEL handler | RoleHierarchy bean defined but not wired to MethodSecurityExpressionHandler | Override methodSecurityExpressionHandler() bean with hierarchy set |
| @PostFilter on large collections | Filters in Java after full DB result is loaded | Add tenant/owner filter to the JPA query; use @PostFilter as a safety net only |
| @PreAuthorize on private methods | AOP proxy cannot intercept private methods | Apply to public methods only; move security logic to a public service method |
Annotating Service vs Controller Layer
A common architectural question is whether to place @PreAuthorize on the controller or the service. The recommendation is service layer:
- Controller-only security means that if the service method is called from a scheduled task, a message consumer, or another service, it is unprotected.
- Service-layer security is enforced everywhere the method is called — regardless of the entry point.
- Put authentication checks (e.g.,
isAuthenticated()) at the controller level, and authorization checks (hasRole, ownership) at the service level.
// Controller — authentication only
@RestController
public class OrderController {
@PreAuthorize("isAuthenticated()") // must be logged in
@GetMapping("/api/v1/orders/{id}")
public Order getOrder(@PathVariable String id) {
return orderService.getOrder(id); // authorization enforced here
}
}
// Service — authorization and ownership
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#id, authentication)")
public Order getOrder(String id) { /* ... */ }
}
Conclusion
Spring Security method-level security with @PreAuthorize is the pattern that separates production-grade Spring Boot APIs from toy applications. It enforces authorization at the business logic layer — not just at URL patterns — ensuring that security follows the code regardless of how a method is invoked. The combination of SpEL expressions, role hierarchy, custom security beans, and multi-tenant patterns gives you a complete authorization framework that scales from simple role checks to complex multi-tenant SaaS authorization models.
To configure the identity provider that issues the JWTs that power these authorization checks, see the companion post on Spring Security 6 OAuth2 Resource Server with Keycloak and Auth0. For the custom JWT approach with self-managed tokens, see the JWT token lifecycle guide.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Spring Security