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 current Authentication object
  • principalauthentication.getPrincipal(), typically UserDetails
  • #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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Reality of Working With International Contractors

Hiring international contractors can feel like opening a global talent buffet. But the reality is often a mix of opportunity, miscommunication, and timezone chaos.

Read more

Asynchronous Java With CompletableFuture — Patterns That Stay Readable

CompletableFuture makes async composition possible in Java, but its API surface is large and the error handling semantics are non-obvious. Here are the patterns that produce maintainable async code and the pitfalls that produce callback soup.

Read more

Auckland Backend Developers Cost NZ$130K and the Market Has Maybe 200 Senior Candidates — Here Is the Fix

You've talked to every recruiter in Auckland. They all send you the same five people. Three of them aren't looking.

Read more

Caching at the API Level: The Performance Win Most Backends Skip

Database query optimization and index tuning get the attention. HTTP caching — the layer that can eliminate database hits entirely for read-heavy endpoints — often gets ignored.

Read more