After implementing JWT or Basic authentication and thinking “great, authentication is done!”, the next wall you’ll hit is fine-grained access control. Restricting /admin/** to hasRole('ADMIN') in SecurityFilterChain is straightforward, but URL patterns alone can’t handle cases like “admins can fetch all records, but regular users can only see their own data” on the same endpoint.

That’s where method security comes in. By simply adding @PreAuthorize to a method, you can write access control directly in the service layer. This article covers everything from enabling the feature to writing practical SpEL expressions — at a level you can actually use in production.

When You Need Method Security

URL-based control in SecurityFilterChain is sufficient for coarse-grained rules like “only authenticated users can access this path.” But it falls short in cases like these:

  • GET /api/orders/{id} — users should only be able to view their own orders
  • Admins and regular users share the same endpoint, but the returned data should differ
  • A service method is called from multiple controllers, making it easy for access control to slip through the cracks

Method security acts as a complementary layer to the FilterChain. Rather than trying to handle everything in one place, it’s cleaner to separate responsibilities and combine both approaches.

Enabling @EnableMethodSecurity

First, add @EnableMethodSecurity to a configuration class to activate the feature. Note that the older @EnableGlobalMethodSecurity is deprecated in Spring Security 6 and later.

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
    // SecurityFilterChain configuration, etc.
}

@PreAuthorize and @PostAuthorize are enabled by default. If you also want to use @Secured, explicitly enable it via the attribute:

@EnableMethodSecurity(securedEnabled = true)

Basic Syntax of @PreAuthorize

@PreAuthorize is evaluated before method execution. If the condition is not met, the method is never called — letting you reject unauthorized access at the earliest possible point.

@Service
public class UserService {

    @PreAuthorize("hasRole('ADMIN')")
    public List<User> findAll() {
        return userRepository.findAll();
    }

    @PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")
    public void updateUserStatus(Long userId, boolean active) {
        // ...
    }
}

hasRole('ADMIN') internally converts the string to ROLE_ADMIN before matching. In contrast, hasAuthority('ROLE_ADMIN') performs a direct string comparison. If you use custom authority names (e.g., READ_PRIVILEGE), hasAuthority() is the more natural choice.

Referencing Authentication Information with SpEL

Inside @PreAuthorize, you can use SpEL (Spring Expression Language). The authentication object gives you access to the currently logged-in user’s information.

// Reference the logged-in username
@PreAuthorize("authentication.name == #username")
public UserProfile getProfile(String username) {
    return profileRepository.findByUsername(username);
}

Prefixing a parameter name with # (e.g., #username) lets you reference method arguments inside SpEL. By comparing authentication.name against the argument, you can enforce “only fetch your own profile” in a single line.

If you have a custom UserDetails implementation, you can access its fields via principal:

@PreAuthorize("principal.id == #userId")
public void deleteAccount(Long userId) {
    userRepository.deleteById(userId);
}

Implementing Ownership Checks

The pattern “users can only operate on their own resources, but admins are the exception” is a common one:

@PreAuthorize("hasRole('ADMIN') or authentication.name == #order.ownerUsername")
public void cancelOrder(OrderRequest order) {
    orderRepository.cancel(order.getId());
}

When SpEL expressions get complex, you can create a custom PermissionEvaluator and use hasPermission() to simplify them. That said, writing SpEL directly is sufficient for most cases when you’re starting out.

When to Use @PostAuthorize

@PostAuthorize is evaluated after method execution. Use it when you want to base the condition on the return value (returnObject).

@PostAuthorize("returnObject.ownerName == authentication.name or hasRole('ADMIN')")
public Document findDocument(Long id) {
    return documentRepository.findById(id).orElseThrow();
}

One important caveat: the method has already executed, so the database has already been hit. If you use @PostAuthorize on operations with side effects (such as data updates), you’ll end up in a situation where the operation ran but the response is denied. Limit its use to read-only operations as a general rule.

Comparison with @Secured

@Secured is a legacy annotation that doesn’t support SpEL — it simply enumerates role strings.

@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void someAdminOperation() {
    // ...
}

In Spring Security 6 and later, @Secured holds no functional advantage. For new projects, it’s recommended to standardize on @PreAuthorize. If your existing codebase uses @Secured, gradually migrating it to @PreAuthorize whenever you touch those files is a reasonable approach.

Behavior When Access Is Denied

When authorization fails, an AccessDeniedException is thrown and HTTP 403 is returned by default. Unauthenticated requests (not logged in) result in an AuthenticationException with a 401, so the two are distinct.

For JSON APIs, the default 403 response may return HTML. Registering an AccessDeniedHandler Bean to return a custom response is a good practice:

@Bean
public AccessDeniedHandler accessDeniedHandler() {
    return (request, response, ex) -> {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write("{\"error\": \"Access denied\"}");
    };
}

Register this in the exceptionHandling configuration of your SecurityFilterChain.

Testing with @WithMockUser

For testing method security, @WithMockUser from spring-security-test is very convenient:

@SpringBootTest
class UserServiceTest {

    @Autowired
    private UserService userService;

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminCanFetchAllUsers() {
        assertDoesNotThrow(() -> userService.findAll());
    }

    @Test
    @WithMockUser(roles = "USER")
    void regularUserGets403WhenFetchingAllUsers() {
        assertThrows(AccessDeniedException.class, () -> userService.findAll());
    }
}

@WithMockUser sets a user with the specified role in the Spring Security context. If you want to exercise the actual UserDetailsService, use @WithUserDetails instead.

Because method security operates via AOP, you need to test against real Beans — use @SpringBootTest or @SpringBootTest(webEnvironment = NONE) rather than mocks. If you’re curious about the AOP fundamentals, see Introduction to AOP in Spring Boot.

Summary

Key points for adopting method security:

  • Add @EnableMethodSecurity to a configuration class to enable it (Spring Security 6 and later)
  • Add @PreAuthorize to service methods to write role and authority checks
  • SpEL expressions referencing authentication.name and method arguments make ownership checks concise
  • Limit @PostAuthorize to return-value-based control; avoid it on operations with side effects
  • Do not use @Secured in new code — standardize on @PreAuthorize
  • Think of SecurityFilterChain as the entry-point for access control and method security as the fine-grained control layer, and keep their responsibilities separate

Once JWT authentication is in place, enabling @EnableMethodSecurity and adding @PreAuthorize annotations is all it takes to get RBAC working. For details on implementing JWT authentication, see How to Implement JWT Authentication in Spring Boot.