00:00

Method-Level Security

Last updated:

Method-level security in Spring Security puts access rules directly on your service methods, such as "only admins may refund" or "customers may only read their own orders". URL rules in the filter chain can say who may call /api/orders/**. They can't say which orders a user may touch. Method-level security closes that gap, and because the rules sit on the business methods, they still apply when the same method is called from a scheduled job or another service.

This tutorial explains how Spring Security enforces method rules with proxies, and how to enable them with @EnableMethodSecurity. It covers @PreAuthorize, @PostAuthorize, @PreFilter and @PostFilter, SpEL expressions with method parameters, custom permission beans, meta-annotations, and masking values with @HandleAuthorizationDenied. It then secures a complete order API on Spring Boot 4.1, shows the real failures behind the most common mistakes, and tests the rules with @WithMockUser.

1. What Is Method-Level Security?

Method-level security means annotating a Spring bean's methods with an authorization rule, a condition that must be true for the current user. Spring checks the rule every time the method is called. If the check fails, the method doesn't run (or, for "post" rules, its result isn't returned), and Spring throws an AuthorizationDeniedException, which a web request turns into 403 Forbidden.

Rules are written in SpEL (Spring Expression Language). An expression can check roles (hasRole('ADMIN')), the current user (authentication.name), method arguments (#customer), the return value (returnObject) and even other beans (@orderAccess.canCancel(...)). If Spring Security itself is new to you, read the Spring Security introduction first.

2. URL Security vs Method-Level Security

URL security (authorizeHttpRequests)Method-level security
Where the rule livesOne SecurityFilterChain configurationOn each service method
Can seePath, HTTP method, user and rolesUser and roles, plus method arguments, return values and your own beans
Applies toHTTP requests onlyEvery call through the Spring bean: HTTP, messaging, scheduled jobs
Typical rule"/api/** needs a logged-in user""customers may cancel only their own unshipped orders"

Use both. Keep URL rules broad (who must be logged in), and put the detailed decisions on the methods that make them. This is the layering Figure 1 shows.

URL security and method-level security as two layersAn HTTP request such as GET /api/orders/ORD-1002 first passes the security filter chain, which logs the user in with HTTP Basic and applies the URL rule authenticated; missing or wrong credentials give 401 Unauthorized. The OrderController contains no security code. The call then reaches the OrderService proxy, which checks the @PreAuthorize, @PostAuthorize and @PostFilter rules; a failed rule throws AuthorizationDeniedException and gives 403 Forbidden. Only allowed calls reach the OrderService business logic. HTTP request GET /api/orders/ ORD-1002 Security filter chain login (HTTP Basic) and URL rule: authenticated() OrderController no security code OrderService proxy @PreAuthorize, @PostAuthorize, @PostFilter rules OrderService business logic 401 Unauthorized no or wrong credentials 403 Forbidden AuthorizationDeniedException who are you? may you do this, to this order?
Figure 1: the filter chain answers "who are you?"; method rules answer "may you do this, to this order?"

3. How Method Security Works: Proxies and Interceptors

@EnableMethodSecurity registers method interceptors. Spring then wraps every bean that has security annotations in a proxy, a generated object that sits in front of the real one, the same mechanism used by Spring AOP and @Transactional:

How the method security proxy checks a callOrderController calls orderService.refund(id), which actually reaches a proxy created by @EnableMethodSecurity. The before interceptor evaluates @PreAuthorize, for example hasRole ADMIN; if it is denied, the method never runs and AuthorizationDeniedException is thrown. If allowed, the real OrderService object runs refund(id). The after interceptor then evaluates @PostAuthorize or @PostFilter against the returned object; if denied, the method has already run but its result is not returned. A call from goodwillRefund to this.refund stays inside the real object and is never checked. OrderController orderService .refund(id) Proxy created by @EnableMethodSecurity Before interceptor @PreAuthorize hasRole('ADMIN')? OrderService (real object) refund(id) runs only if allowed After interceptor @PostAuthorize / @PostFilter checks returnObject goodwillRefund() → this.refund(): stays inside, no check Denied: method never runs AuthorizationDeniedException Denied: method already ran result is not returned
Figure 2: checks happen in the proxy around the real object; calls on this never pass through it.
  • AuthorizationManagerBeforeMethodInterceptor evaluates @PreAuthorize (and @Secured) before the method runs. On failure the method never executes.
  • AuthorizationManagerAfterMethodInterceptor evaluates @PostAuthorize after the method has run, with the return value available as returnObject.
  • @PreFilter and @PostFilter have their own interceptors that remove elements from a collection argument or return value.

Because the checks live in the proxy, only calls that go through the proxy are checked. A method that calls another method on this skips the check entirely (section 7.2).

4. Setup: Enabling @EnableMethodSecurity

Add the security starter, plus the test starter for section 8:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Then add @EnableMethodSecurity to a configuration class. Spring Boot does not turn method security on by itself:

SecurityConfig.java (excerpt)
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
    // ...
}
AttributeDefaultEnables
prePostEnabledtrue@PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter
securedEnabledfalse@Secured("ROLE_ADMIN"): role lists only, no SpEL
jsr250Enabledfalse@RolesAllowed, @PermitAll, @DenyAll from jakarta.annotation.security
Version note: @EnableMethodSecurity replaced @EnableGlobalMethodSecurity(prePostEnabled = true) in Spring Security 5.6. The old annotation is deprecated, and in the new one, pre/post annotations are on by default. Spring Boot 4.1 uses Spring Security 7.1. @HandleAuthorizationDenied, used in section 5.6, was added in Spring Security 6.3.

5. Method Security Annotations

5.1 @PreAuthorize: Check Before the Method Runs

The most used annotation. Arguments are available by name with #, because Spring Boot compiles with -parameters:

OrderService.java (excerpt)
// Only customers place orders, and only for themselves
@PreAuthorize("hasRole('CUSTOMER') and #customer == authentication.name")
public Order placeOrder(String customer, PlaceOrderRequest request) { ... }
ExpressionTrue when
hasRole('ADMIN')The user has the authority ROLE_ADMIN (the prefix is added for you)
hasAnyRole('SUPPORT', 'ADMIN')The user has at least one of the roles
hasAuthority('order:refund')The user has exactly this authority string, with no prefix added
authentication.nameThe logged-in username, for comparisons
#customer == authentication.nameThe customer argument is the current user
isAuthenticated(), permitAll(), denyAll()Logged in / always / never

5.2 @PostAuthorize: Check the Returned Object

Some rules depend on data you only have after loading it, such as the owner of an order. @PostAuthorize runs the method, then checks returnObject:

OrderService.java (excerpt)
@PostAuthorize("returnObject.customer() == authentication.name or hasAnyRole('SUPPORT', 'ADMIN')")
public Order findById(String id) {
    return orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}

The method has already run when the check happens, so use @PostAuthorize only on read-only methods. A denied call must not leave anything changed behind.

5.3 @PreFilter and @PostFilter: Filter Collections

@PostFilter removes each element of a returned collection for which the expression is false. filterObject is the current element:

OrderService.java (excerpt)
@PostFilter("filterObject.customer() == authentication.name or hasAnyRole('SUPPORT', 'ADMIN')")
public List<Order> findAll() {
    return orderRepository.findAll();
}

@PreFilter does the same for a collection argument. Both work in memory after the data is loaded, so for large tables filter in the query instead, for example findByCustomer(authentication.getName()).

5.4 Custom Rules in a Bean

When a rule needs a database lookup or several conditions, put it in a bean and call it from SpEL with @beanName. It is plain Java, so it can be unit-tested and reused:

OrderService.java and OrderAccess.java (excerpts)
@PreAuthorize("@orderAccess.canCancel(#id, authentication)")
public Order cancel(String id) { ... }

@Component("orderAccess")
public class OrderAccess {

    public boolean canCancel(String orderId, Authentication authentication) {
        // admins: always; customers: only their own orders that haven't shipped
    }
}

5.5 Meta-Annotations for Reusable Rules

Repeating @PreAuthorize("hasRole('ADMIN')") invites typos. A meta-annotation gives the rule a name. Annotate your own annotation with @PreAuthorize, then use it on methods:

IsAdmin.java and OrderService.java (excerpts)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface IsAdmin {
}

@IsAdmin
public Order refund(String id) { ... }

5.6 @HandleAuthorizationDenied: Return a Fallback Instead of 403

Sometimes a denied user should still get an answer, just a reduced one. @HandleAuthorizationDenied names a MethodAuthorizationDeniedHandler bean whose result is returned instead of throwing. Here non-admins see a masked payment reference:

OrderService.java and MaskedValueHandler.java (excerpts)
@PreAuthorize("hasRole('ADMIN')")
@HandleAuthorizationDenied(handlerClass = MaskedValueHandler.class)
public String paymentReference(String id) { ... }

@Component
public class MaskedValueHandler implements MethodAuthorizationDeniedHandler {

    @Override
    public Object handleDeniedInvocation(MethodInvocation invocation, AuthorizationResult result) {
        return "****";
    }
}

6. A Complete Example: Securing an Order API

The shop has three roles: CUSTOMER (asha, ravi), SUPPORT (meera) and ADMIN (admin). Customers place orders for themselves, read and list only their own, and may cancel them until they ship. Support reads every order. Only admins refund and see payment references. The URL rule only demands a login, and every business rule lives in OrderService. Errors are consistent: 401 without a login, 403 when a rule says no, and 404 for an unknown order.

6.1 Security Configuration

package com.javabytechie.shop.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity // turns on @PreAuthorize, @PostAuthorize, @PreFilter and @PostFilter
public class SecurityConfig {

    // URL rules stay coarse: every API call needs a logged-in user.
    // Who may do what with which order is decided on the service methods.
    @Bean
    public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(csrf -> csrf.disable()); // stateless API with HTTP Basic, no browser session
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    // Demo users; a real shop would load them from a database or an identity provider
    @Bean
    public UserDetailsService users(PasswordEncoder encoder) {
        return new InMemoryUserDetailsManager(
                User.withUsername("asha").password(encoder.encode("asha-pass")).roles("CUSTOMER").build(),
                User.withUsername("ravi").password(encoder.encode("ravi-pass")).roles("CUSTOMER").build(),
                User.withUsername("meera").password(encoder.encode("meera-pass")).roles("SUPPORT").build(),
                User.withUsername("admin").password(encoder.encode("admin-pass")).roles("ADMIN").build());
    }
}

The in-memory users keep the example self-contained. With JWT or OAuth2 login, the same method rules work unchanged, because they only look at the Authentication in the security context.

6.2 The Secured Service and Its Helpers

package com.javabytechie.shop.order;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authorization.method.HandleAuthorizationDenied;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // Only customers place orders, and only for themselves
    @PreAuthorize("hasRole('CUSTOMER') and #customer == authentication.name")
    public Order placeOrder(String customer, PlaceOrderRequest request) {
        Order order = new Order(orderRepository.nextId(), customer, request.sku(), request.total(),
                OrderStatus.PLACED, "PAY-" + System.nanoTime() % 100_000_000);
        log.info("{} placed {}", customer, order.id());
        return orderRepository.save(order);
    }

    // The owner, support and admins may read an order; checked after loading it
    @PostAuthorize("returnObject.customer() == authentication.name or hasAnyRole('SUPPORT', 'ADMIN')")
    public Order findById(String id) {
        return orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
    }

    // Customers see their own orders; support and admins see all of them
    @PostFilter("filterObject.customer() == authentication.name or hasAnyRole('SUPPORT', 'ADMIN')")
    public List<Order> findAll() {
        return orderRepository.findAll();
    }

    @PreAuthorize("@orderAccess.canCancel(#id, authentication)")
    public Order cancel(String id) {
        Order order = orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
        log.info("Cancelled {}", id);
        return orderRepository.save(order.withStatus(OrderStatus.CANCELLED));
    }

    @IsAdmin
    public Order refund(String id) {
        Order order = orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
        log.info("Refunded {}", id);
        return orderRepository.save(order.withStatus(OrderStatus.REFUNDED));
    }

    // Admins see the payment reference; everyone else gets "****" instead of a 403
    @PreAuthorize("hasRole('ADMIN')")
    @HandleAuthorizationDenied(handlerClass = MaskedValueHandler.class)
    public String paymentReference(String id) {
        return orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id)).paymentReference();
    }
}

6.3 Controller, DTOs and Error Handling

The controller returns OrderResponse, which leaves out the payment reference. Otherwise the masking in paymentReference() would be pointless. The exception handler turns AuthorizationDeniedException into a 403 ProblemDetail, matching the 404 format. See the exception handling tutorial for more on ProblemDetail:

package com.javabytechie.shop.order;

import java.net.URI;
import java.util.List;
import java.util.Map;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> place(@RequestBody PlaceOrderRequest request, Authentication authentication) {
        Order order = orderService.placeOrder(authentication.getName(), request);
        return ResponseEntity.created(URI.create("/api/orders/" + order.id())).body(OrderResponse.from(order));
    }

    @GetMapping
    public List<OrderResponse> findAll() {
        return orderService.findAll().stream().map(OrderResponse::from).toList();
    }

    @GetMapping("/{id}")
    public OrderResponse findById(@PathVariable String id) {
        return OrderResponse.from(orderService.findById(id));
    }

    @PostMapping("/{id}/cancel")
    public OrderResponse cancel(@PathVariable String id) {
        return OrderResponse.from(orderService.cancel(id));
    }

    @PostMapping("/{id}/refund")
    public OrderResponse refund(@PathVariable String id) {
        return OrderResponse.from(orderService.refund(id));
    }

    @GetMapping("/{id}/payment-reference")
    public Map<String, String> paymentReference(@PathVariable String id) {
        return Map.of("paymentReference", orderService.paymentReference(id));
    }
}

6.4 Run It

Start the app and call the API as different users. The same endpoint gives each user a different answer:

Terminal
$ curl -u asha:asha-pass localhost:8080/api/orders
HTTP/1.1 200
[{"id":"ORD-1001","customer":"asha","sku":"KB-100","total":89.00,"status":"PLACED"},{"id":"ORD-1003","customer":"asha","sku":"HD-300","total":149.00,"status":"SHIPPED"}]

$ curl -u meera:meera-pass localhost:8080/api/orders
HTTP/1.1 200
[{"id":"ORD-1001",...,"customer":"asha",...},{"id":"ORD-1002",...,"customer":"ravi",...},{"id":"ORD-1003",...,"customer":"asha",...}]

$ curl -u asha:asha-pass localhost:8080/api/orders/ORD-1002
HTTP/1.1 403
{"detail":"You are not allowed to do this","instance":"/api/orders/ORD-1002","status":403,"title":"Forbidden"}

$ curl -u asha:asha-pass localhost:8080/api/orders/ORD-1003/payment-reference
{"paymentReference":"****"}

$ curl -u admin:admin-pass localhost:8080/api/orders/ORD-1003/payment-reference
{"paymentReference":"PAY-7781-4412"}

$ curl -i localhost:8080/api/orders
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"

Cancelling, refunding and placing orders follow the rules in OrderAccess and the annotations:

Terminal
# ORD-1003 has already shipped
$ curl -u asha:asha-pass -X POST localhost:8080/api/orders/ORD-1003/cancel
HTTP/1.1 403
{"detail":"You are not allowed to do this","instance":"/api/orders/ORD-1003/cancel","status":403,"title":"Forbidden"}

$ curl -u asha:asha-pass -X POST localhost:8080/api/orders/ORD-1001/cancel
HTTP/1.1 200
{"id":"ORD-1001","customer":"asha","sku":"KB-100","total":89.00,"status":"CANCELLED"}

$ curl -u meera:meera-pass -X POST localhost:8080/api/orders/ORD-1002/refund
HTTP/1.1 403
{"detail":"You are not allowed to do this","instance":"/api/orders/ORD-1002/refund","status":403,"title":"Forbidden"}

$ curl -u admin:admin-pass -X POST localhost:8080/api/orders/ORD-1002/refund
HTTP/1.1 200
{"id":"ORD-1002","customer":"ravi","sku":"MS-200","total":25.50,"status":"REFUNDED"}

$ curl -u asha:asha-pass -X POST localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"sku":"CB-400","total":19.99}'
HTTP/1.1 201
{"id":"ORD-1004","customer":"asha","sku":"CB-400","total":19.99,"status":"PLACED"}

$ curl -u meera:meera-pass -X POST localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"sku":"CB-400","total":19.99}'
HTTP/1.1 403

7. Common Pitfalls and How to Fix Them

7.1 Annotations Are Silently Ignored

Without @EnableMethodSecurity, nothing fails. The annotations simply do nothing. With it removed, a customer read someone else's order and support issued a refund:

Terminal (@EnableMethodSecurity removed)
$ curl -u asha:asha-pass localhost:8080/api/orders/ORD-1002
HTTP/1.1 200
{"id":"ORD-1002","customer":"ravi","sku":"MS-200","total":25.50,"status":"SHIPPED"}

$ curl -u meera:meera-pass -X POST localhost:8080/api/orders/ORD-1002/refund
HTTP/1.1 200
{"id":"ORD-1002","customer":"ravi","sku":"MS-200","total":25.50,"status":"REFUNDED"}

How to fix it: add @EnableMethodSecurity, and keep at least one test that expects a denial (section 8), so this can't break unnoticed.

7.2 Self-Invocation Bypasses the Check

A public method without an annotation called refund(id) on this. The call never went through the proxy, so a customer refunded an order:

OrderService.java (the mistake) and test output
// No annotation here, and the call below goes to this, not through the proxy
public Order goodwillRefund(String id) {
    return refund(id);
}

// As user asha (CUSTOMER):
// refund("PRB-1")          -> AuthorizationDeniedException: Access Denied
// goodwillRefund("PRB-1")  -> allowed, status REFUNDED

How to fix it: put a rule on every public entry point, or move the secured method to another bean so the call goes through its proxy. Rules on private methods never run at all.

7.3 hasAuthority('ADMIN') Denies an Admin

Test output (user created with roles = "ADMIN")
@PreAuthorize("hasAuthority('ADMIN')")   -> AuthorizationDeniedException: Access Denied
@PreAuthorize("hasRole('ROLE_ADMIN')")   -> allowed
@PreAuthorize("hasRole('ADMIN')")        -> allowed

roles("ADMIN") creates the authority ROLE_ADMIN. hasRole('ADMIN') adds the ROLE_ prefix before comparing; hasAuthority compares the exact string. How to fix it: use hasRole for roles and hasAuthority for permission-style authorities such as order:refund, and never mix them up.

7.4 A Catch-All Exception Handler Turns 403 into 500

With an @ExceptionHandler(Exception.class) and no handler for access denials, the denial is caught as a generic error:

Terminal (catch-all handler, no AuthorizationDeniedException handler)
$ curl -u asha:asha-pass localhost:8080/api/orders/ORD-1002
HTTP/1.1 500
{"detail":"Something went wrong","instance":"/api/orders/ORD-1002","status":500,"title":"Internal Server Error"}

How to fix it: handle AuthorizationDeniedException (a subclass of AccessDeniedException) explicitly with 403, as OrderExceptionHandler does, or don't catch Exception at all and let Spring Security answer.

7.5 @PostFilter Runs the Query Before Checking Anything

@PostFilter loads every row and then discards the ones the user may not see. Even with no user logged in, the method ran and only the filtering failed. How to fix it: combine it with a @PreAuthorize("isAuthenticated()") or a URL rule, and for large data sets filter in the database query instead.

8. Testing Method Security with @WithMockUser

@WithMockUser from spring-security-test puts a user with the given name and roles into the security context for one test. The service can then be called directly, with no HTTP request at all. Every test checks the order's state before and after, so a denied call is proven to have changed nothing:

OrderServiceSecurityTest.java
package com.javabytechie.shop.order;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.test.context.support.WithMockUser;

// Calls the service directly: method security works without any HTTP request
@SpringBootTest
class OrderServiceSecurityTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @BeforeEach
    void freshTestOrders() {
        // Saved through the repository, which has no security rules
        orderRepository.save(new Order("TST-1", "asha", "KB-100", new BigDecimal("89.00"), OrderStatus.PLACED, "PAY-TST-1"));
        orderRepository.save(new Order("TST-2", "ravi", "MS-200", new BigDecimal("25.50"), OrderStatus.PLACED, "PAY-TST-2"));
    }

    private OrderStatus statusOf(String id) {
        return orderRepository.findById(id).orElseThrow().status();
    }

    @Test
    @WithMockUser(username = "asha", roles = "CUSTOMER")
    void customerReadsOwnOrderButNotSomeoneElses() {
        assertThat(orderService.findById("TST-1").customer()).isEqualTo("asha");

        assertThatThrownBy(() -> orderService.findById("TST-2"))
                .isInstanceOf(AuthorizationDeniedException.class);
    }

    @Test
    @WithMockUser(username = "meera", roles = "SUPPORT")
    void supportReadsAnyOrder() {
        assertThat(orderService.findById("TST-2").customer()).isEqualTo("ravi");
    }

    @Test
    @WithMockUser(username = "asha", roles = "CUSTOMER")
    void listIsFilteredToTheCustomersOwnOrders() {
        assertThat(orderService.findAll()).isNotEmpty().allMatch(order -> order.customer().equals("asha"));
    }

    @Test
    @WithMockUser(username = "asha", roles = "CUSTOMER")
    void customerCannotPlaceAnOrderForSomeoneElse() {
        int before = orderRepository.findAll().size();

        assertThatThrownBy(() -> orderService.placeOrder("ravi", new PlaceOrderRequest("KB-100", new BigDecimal("89.00"))))
                .isInstanceOf(AuthorizationDeniedException.class);

        assertThat(orderRepository.findAll()).hasSize(before);
    }

    @Test
    @WithMockUser(username = "ravi", roles = "CUSTOMER")
    void customerCannotCancelSomeoneElsesOrder() {
        assertThat(statusOf("TST-1")).isEqualTo(OrderStatus.PLACED);

        assertThatThrownBy(() -> orderService.cancel("TST-1")).isInstanceOf(AuthorizationDeniedException.class);

        assertThat(statusOf("TST-1")).isEqualTo(OrderStatus.PLACED);
    }

    @Test
    @WithMockUser(username = "asha", roles = "CUSTOMER")
    void customerCancelsOwnPlacedOrder() {
        assertThat(statusOf("TST-1")).isEqualTo(OrderStatus.PLACED);

        orderService.cancel("TST-1");

        assertThat(statusOf("TST-1")).isEqualTo(OrderStatus.CANCELLED);
    }

    @Test
    @WithMockUser(username = "meera", roles = "SUPPORT")
    void onlyAdminsRefund() {
        assertThatThrownBy(() -> orderService.refund("TST-2")).isInstanceOf(AuthorizationDeniedException.class);

        assertThat(statusOf("TST-2")).isEqualTo(OrderStatus.PLACED);
    }

    @Test
    @WithMockUser(username = "admin", roles = "ADMIN")
    void adminRefundsAndSeesThePaymentReference() {
        orderService.refund("TST-2");

        assertThat(statusOf("TST-2")).isEqualTo(OrderStatus.REFUNDED);
        assertThat(orderService.paymentReference("TST-2")).isEqualTo("PAY-TST-2");
    }

    @Test
    @WithMockUser(username = "asha", roles = "CUSTOMER")
    void paymentReferenceIsMaskedForNonAdmins() {
        assertThat(orderService.paymentReference("TST-1")).isEqualTo("****");
    }

    @Test
    void noUserAtAllIsRejected() {
        assertThatThrownBy(() -> orderService.refund("TST-2"))
                .isInstanceOf(AuthenticationCredentialsNotFoundException.class);

        assertThat(statusOf("TST-2")).isEqualTo(OrderStatus.PLACED);
    }
}

A second test checks the HTTP layer: 401 without credentials, 200 for the owner, 403 for another customer and 404 for an unknown order. httpBasic(...) adds real credentials to a MockMvc request:

OrderApiSecurityTest.java
package com.javabytechie.shop.order;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

@SpringBootTest
@AutoConfigureMockMvc
class OrderApiSecurityTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void anonymousRequestGets401() {
        assertThat(mvc.get().uri("/api/orders/ORD-1003")).hasStatus(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void ownerGets200AndOtherCustomerGets403() {
        assertThat(mvc.get().uri("/api/orders/ORD-1003").with(httpBasic("asha", "asha-pass")))
                .hasStatusOk()
                .bodyJson().extractingPath("$.customer").isEqualTo("asha");

        assertThat(mvc.get().uri("/api/orders/ORD-1003").with(httpBasic("ravi", "ravi-pass")))
                .hasStatus(HttpStatus.FORBIDDEN)
                .bodyJson().extractingPath("$.detail").isEqualTo("You are not allowed to do this");
    }

    @Test
    void unknownOrderGets404EvenForAdmins() {
        assertThat(mvc.get().uri("/api/orders/ORD-9999").with(httpBasic("admin", "admin-pass")))
                .hasStatus(HttpStatus.NOT_FOUND);
    }
}
Console
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderServiceSecurityTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderApiSecurityTest
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0

For plain unit tests of OrderAccess, no Spring is needed. Pass a hand-built Authentication, as in the JUnit and Mockito tutorial.

9. When to Use Method-Level Security

Good fitPoor fit
Ownership rules: "only your own orders"Simple "must be logged in" rules for whole URL areas; use the filter chain
Business operations reached from several entry points: REST, messages, jobsFiltering thousands of rows per request; filter in the query
Role- or permission-based actions such as refunds and approvalsRules that need request details such as IP or headers; use the web layer
Hiding or masking single fields for some usersPrivate helper methods, which proxies can't intercept

10. Best Practices

  • Enable method security explicitly with @EnableMethodSecurity, and test that denials happen.
  • Keep URL rules coarse and method rules precise: login at the URL, ownership and roles on the service.
  • Secure the service layer, not just controllers, so every entry point is protected.
  • Prefer @PreAuthorize; use @PostAuthorize only on read-only methods.
  • Move complex rules into a bean and call it as @bean.method(...) from SpEL.
  • Name repeated rules with meta-annotations such as @IsAdmin.
  • Use hasRole for roles and hasAuthority for permissions, consistently.
  • Return DTOs that leave out fields some users must not see.
  • Map AuthorizationDeniedException to 403 in your exception handler; never let it become a 500.
  • Avoid self-invocation of secured methods.
  • Test every rule both ways with @WithMockUser: one allowed user, one denied user, and the state before and after.

11. Summary

Method-level security moves fine-grained authorization onto the methods that make business decisions. Enable it with @EnableMethodSecurity, then guard methods with @PreAuthorize for checks on roles and arguments, @PostAuthorize for checks on returned data, and @PreFilter and @PostFilter for collections. Put complex rules in a bean, give common rules a meta-annotation, and use @HandleAuthorizationDenied when a masked value is better than a 403. Remember that the checks live in a proxy, so self-invocation and private methods bypass them, and that hasRole adds the ROLE_ prefix while hasAuthority doesn't. The main recommendation: keep URL rules simple, secure every public service method that changes or reveals data, and prove each rule with an allowed and a denied test.