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.

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Spring Security

Spring Security April 4, 2026 15 min read Spring Security Series
Spring Security method-level security @PreAuthorize RBAC

Enabling Method-Level Security

Spring Security Method-Level RBAC Architecture | mdsanwarhossain.me
Spring Security Method-Level RBAC — mdsanwarhossain.me

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

RBAC Security Patterns | mdsanwarhossain.me
RBAC Security Patterns — mdsanwarhossain.me

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 securityCalling a secured method from within the same bean skips the AOP proxyMove the secured method to a different bean, or inject the proxy via @Autowired into self
#param binding failsParameter names compiled awayEnsure -parameters compiler flag (Spring Boot default) or use @Param
hasRole vs hasAuthority confusionhasRole prepends ROLE_; hasAuthority is exactUse hasRole('ADMIN') when GrantedAuthority is ROLE_ADMIN
RoleHierarchy not in SpEL handlerRoleHierarchy bean defined but not wired to MethodSecurityExpressionHandlerOverride methodSecurityExpressionHandler() bean with hierarchy set
@PostFilter on large collectionsFilters in Java after full DB result is loadedAdd tenant/owner filter to the JPA query; use @PostFilter as a safety net only
@PreAuthorize on private methodsAOP proxy cannot intercept private methodsApply 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 — 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

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Spring Security

Last updated: April 4, 2026