After implementing JWT or Basic authentication and getting authentication working, the next wall you hit is fine-grained access control. Setting /admin/** to hasRole('ADMIN') in a SecurityFilterChain is easy, but cases like “admins can fetch all records, while regular users can only see their own data” on the same endpoint can’t be handled by URL patterns alone.

This is 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 method security to writing practical SpEL expressions, at a level you can actually use.

When you need method security

When the call relationships in your service layer become complex and it’s hard to see what’s calling what, it’s also a good time to revisit the separation of responsibilities between modules. How to Decouple Modules in Spring Boot Using ApplicationEvent explains concrete examples of decoupling dependencies with an event-driven approach.

URL-level control via SecurityFilterChain is sufficient for broad control like “only authenticated users can access this path.” But it falls short in cases like the following:

  • GET /api/orders/{id} where you want users to only be able to view their own orders
  • Admins and regular users share the same endpoint, but you want to change the data returned
  • A service method is called from multiple controllers, making it easy for access control to be missed somewhere

Method security functions as a “complementary layer” to the FilterChain. Rather than trying to do everything with one or the other, it’s cleaner to split responsibilities and combine both.

Enabling @EnableMethodSecurity

Key attributes of @EnableMethodSecurity

@EnableMethodSecurity has several attributes that let you control which annotations are enabled and the implementation mode.

AttributeDefaultRole
prePostEnabledtrueEnables @PreAuthorize / @PostAuthorize
securedEnabledfalseEnables @Secured
jsr250EnabledfalseEnables @RolesAllowed / @PermitAll / @DenyAll
modePROXYAOP implementation method (PROXY or ASPECTJ)
proxyTargetClassfalseWhether to force a CGLIB proxy
@Configuration
@EnableMethodSecurity(
    prePostEnabled = true,
    securedEnabled = true,
    jsr250Enabled = true
)
public class SecurityConfig {}

A common pitfall: AOP doesn’t apply on self-invocation

Self-invocation often happens in the background when a single class ends up carrying too many responsibilities. If you want to explicitly verify package boundaries, How to Achieve a Modular Monolith with Spring Modulith is also a good reference.

Method security works via Spring AOP proxies. If you call another method within the same class directly as this.method(), it bypasses the proxy, so the annotation has no effect.

@Service
public class ReportService {

    public void run() {
        // ❌ self-invocation: @PreAuthorize is ignored
        this.adminOnly();
    }

    @PreAuthorize("hasRole('ADMIN')")
    public void adminOnly() { /* ... */ }
}

The workarounds are either (1) extracting the method into a separate Bean and injecting it, or (2) obtaining your own proxy via AopContext.currentProxy(). Option (1) is generally recommended.

Also, neither CGLIB nor JDK proxies can apply annotations to private / final methods or static methods, so make sure to put the annotation on public methods.

First, add @EnableMethodSecurity to a configuration class to enable the feature. Note that as of Spring Security 6, the old @EnableGlobalMethodSecurity is deprecated.

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

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

@EnableMethodSecurity(securedEnabled = true)

Basic syntax of @PreAuthorize

Choosing between hasRole / hasAuthority / hasAnyRole

The three main methods available for permission checks in SpEL are as follows. Since they’re easy to confuse, let’s organize the differences in a table.

MethodWhat it comparesPrefixMain use case
hasRole('ADMIN')Automatically prepends the ROLE_ prefix before comparingNot needed (auto-prepended)Role-based (ROLE_ADMIN format)
hasAuthority('ROLE_ADMIN')Exact string matchRequired (explicit)Custom permission names, fine-grained permissions
hasAnyRole('ADMIN','MANAGER')OR condition across multiple rolesNot needed (auto-prepended)Allowing multiple roles
hasAnyAuthority('READ','WRITE')Exact-match OR across multiple authoritiesRequiredAllowing multiple custom permissions

In other words, hasRole('ADMIN') and hasAuthority('ROLE_ADMIN') effectively produce the same result. However, if you’re dealing with a permission name that doesn’t have a ROLE_ prefix, such as hasAuthority('READ_PRIVILEGE'), hasAuthority is the only option.

// Role: hasRole is simpler if you assume the ROLE_ prefix
@PreAuthorize("hasRole('ADMIN')")
public void adminOnly() {}

// Fine-grained permission: use hasAuthority for custom strings without ROLE_
@PreAuthorize("hasAuthority('USER_READ')")
public User readUser(Long id) { return null; }

Which one to use depends on the format in which your UserDetailsService returns GrantedAuthority. If it returns new SimpleGrantedAuthority("ROLE_ADMIN"), hasRole('ADMIN') is fine; if it uses a custom naming scheme like new SimpleGrantedAuthority("USER_READ"), use hasAuthority.

@PreAuthorize is evaluated before method execution. If the condition isn’t met, the method isn’t called at all, so it can block unauthorized access at an early stage.

@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 to the string ROLE_ADMIN for comparison. On the other hand, hasAuthority('ROLE_ADMIN') compares the string directly as-is. If you’re using custom permission names (e.g., READ_PRIVILEGE), it’s more natural to use hasAuthority().

Referencing authentication info with SpEL expressions

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

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

By prefixing with # as in #username, you can reference a method argument inside SpEL. Comparing authentication.name with the argument lets you write “users can only fetch their own profile” control in a single line.

If you’ve implemented a custom UserDetails, you can access its fields via principal.

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

Example implementation of an ownership check

The pattern “a user can only operate on their own resources, except admins” is commonly used.

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

Once SpEL expressions get complex, creating a custom PermissionEvaluator and using hasPermission() can simplify the expressions. That said, writing SpEL directly is often sufficient at first.

Use cases for @PostAuthorize

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

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

However, there’s a caveat. Since the method has already been executed, any DB access has already occurred. If you use @PostAuthorize on an operation with side effects (such as a data update), you end up in a situation where the operation was executed but then rejected—so in principle, limit it to read-only operations.

Comparison with @Secured

@Secured doesn’t support SpEL and is an older annotation that only lets you enumerate role strings.

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

As of Spring Security 6, @Secured no longer has any functional advantage. For new projects, it’s recommended to standardize on @PreAuthorize. Even if existing code has @Secured, it’s fine to gradually replace it with @PreAuthorize whenever you have the chance to touch it.

Behavior when access is denied

Besides writing an individual AccessDeniedHandler, if you want to centrally manage all exception handling with @RestControllerAdvice, Implementing a Production-Ready GlobalExceptionHandler in Spring Boot covers implementation patterns including trace ID attachment and ProblemDetail extensions.

When authorization fails, an AccessDeniedException is thrown, and HTTP 403 is returned by default. For unauthenticated (not logged in) cases, it’s distinguished by an AuthenticationException, resulting in 401.

For JSON APIs, the default 403 response may come back as HTML, so it’s a good idea to register an AccessDeniedHandler Bean to return a custom response.

@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\"}");
    };
}

Set this in the exceptionHandling of your SecurityFilterChain.

Testing with @WithMockUser

For testing method security, @WithMockUser from spring-security-test is handy.

@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 into the Spring Security context. If you want to run the actual UserDetailsService, use @WithUserDetails instead.

Since method security operates via AOP, you need @SpringBootTest or @SpringBootTest(webEnvironment = NONE) using real Beans rather than mocks, in order to directly test it. If you’re curious about the fundamentals of AOP, Introduction to AOP in Spring Boot is also a good reference.

Summary

Here’s a summary of the key points for introducing 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 permission checks
  • Referencing authentication.name or method arguments in SpEL expressions lets you write concise ownership checks
  • Limit @PostAuthorize to control based on the return value, and don’t use it for operations with side effects
  • Don’t use @Secured for new code—standardize on @PreAuthorize
  • Think of SecurityFilterChain as the entry point for access control, and method security as fine-grained control—keep the roles separate

Once JWT authentication is implemented, all you need to do is enable @EnableMethodSecurity and write @PreAuthorize to get RBAC working. For implementing JWT authentication, see How to Implement JWT Authentication in Spring Boot.

Migrating from @EnableGlobalMethodSecurity and what’s inside SecurityExpressionRoot

Older projects sometimes use @EnableGlobalMethodSecurity(prePostEnabled = true), but as of Spring Security 5.6 it’s deprecated, and replacing it with @EnableMethodSecurity is recommended. The major difference is that @EnableMethodSecurity uses the new AuthorizationManager-based evaluation engine by default. When replacing existing code, the SpEL expressions themselves usually work unchanged, so in most cases you can migrate just by swapping the annotation and running your tests.

The SpEL expressions usable inside @PreAuthorize and @PostAuthorize actually reference properties and methods of a class called SecurityExpressionRoot. Here’s a summary of the representative ones:

  • authentication / principal → information about the logged-in user
  • hasRole() / hasAuthority() / hasPermission() → permission checks
  • returnObject → a reference to the return value, usable only in @PostAuthorize
  • filterObject → collection filtering in @PreFilter / @PostFilter (when the target has multiple arguments, specify which one via the annotation’s filterTarget attribute)

Knowing which property is usable with which annotation will help you write SpEL expressions without hesitation.