After implementing JWT authentication or Basic authentication and feeling “OK, authentication is done!”, the next wall you hit is the granularity of access control. Setting /admin/** to hasRole('ADMIN') in SecurityFilterChain is easy, 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. Just by adding @PreAuthorize to a method, you can write access control directly in the service layer. This article explains everything from how to enable it to practical SpEL expression patterns at a level you can actually use.
When You Need Method Security
The URL-based control of SecurityFilterChain is sufficient for coarse-grained control like “only authenticated users can access this path.” But it falls short in cases like these:
GET /api/orders/{id}where users should only be able to view their own orders- Admins and regular users use the same endpoint, but you want to change the returned data
- A service method is called from multiple controllers, making it easy to miss a control check somewhere
Method security works as a “complementary layer” to FilterChain. Rather than trying to do everything with one or the other, it’s cleaner to separate roles and combine them.
Enabling @EnableMethodSecurity
Main Attributes of @EnableMethodSecurity
@EnableMethodSecurity has multiple attributes that let you control which annotations and implementation modes are enabled.
| Attribute | Default | Role |
|---|---|---|
prePostEnabled | true | Enables @PreAuthorize / @PostAuthorize |
securedEnabled | false | Enables @Secured |
jsr250Enabled | false | Enables @RolesAllowed / @PermitAll / @DenyAll |
mode | PROXY | AOP implementation type (PROXY or ASPECTJ) |
proxyTargetClass | false | Whether to force CGLIB proxy |
@Configuration
@EnableMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true
)
public class SecurityConfig {}
A Common Pitfall: AOP Doesn’t Work with Self-Invocation
Method security works through Spring AOP proxies. If you call another method in the same class directly with this.method(), the proxy is bypassed and 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() { /* ... */ }
}
Workarounds are either (1) extract the method into a separate Bean and inject it, or (2) get the proxy of itself via AopContext.currentProxy(). Option (1) is generally recommended.
Also, annotations cannot be applied to private / final / static methods by either CGLIB or JDK proxies, so apply them to public methods.
First, add @EnableMethodSecurity to your configuration class to enable the feature. Note that since Spring Security 6, the legacy @EnableGlobalMethodSecurity is deprecated.
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
// SecurityFilterChain configuration, etc.
}
By default, @PreAuthorize and @PostAuthorize are enabled. 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
There are three main methods in SpEL for authorization checks. Since they’re easy to confuse, here’s a table summarizing the differences.
| Method | Comparison Target | Prefix | Main Use |
|---|---|---|---|
hasRole('ADMIN') | Auto-prepends ROLE_ for comparison | Unnecessary (auto-added) | Role-based (ROLE_ADMIN format) |
hasAuthority('ROLE_ADMIN') | Exact string match | Required (explicit) | Custom authority names, fine-grained permissions |
hasAnyRole('ADMIN','MANAGER') | OR condition across multiple roles | Unnecessary (auto-added) | Allow multiple roles |
hasAnyAuthority('READ','WRITE') | Exact match OR across multiple authorities | Required | Allow multiple custom permissions |
In other words, hasRole('ADMIN') and hasAuthority('ROLE_ADMIN') produce essentially the same result. However, when dealing with authority names that don’t have ROLE_, such as hasAuthority('READ_PRIVILEGE'), hasAuthority is the only choice.
// Role: hasRole is simpler when the ROLE_ prefix is assumed
@PreAuthorize("hasRole('ADMIN')")
public void adminOnly() {}
// Fine-grained authority: use hasAuthority for custom strings without ROLE_
@PreAuthorize("hasAuthority('USER_READ')")
public User readUser(Long id) { return null; }
Which 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 custom naming like new SimpleGrantedAuthority("USER_READ"), use hasAuthority.
@PreAuthorize is evaluated before the method executes. If the condition isn’t met, the method isn’t called at all, so unauthorized access is rejected 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. Meanwhile, hasAuthority('ROLE_ADMIN') compares strings as-is. If you’re using custom authority names (e.g., READ_PRIVILEGE), hasAuthority() is the more natural choice.
Referencing Authentication Information with SpEL Expressions
Inside @PreAuthorize, you can use SpEL (Spring Expression Language). You can access information about the logged-in user 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 # like #username, you can reference method arguments inside the SpEL expression. By comparing authentication.name with the argument, you can express “only fetch your own profile” in a single line.
If you’ve implemented a custom UserDetails, you can access fields via principal.
@PreAuthorize("principal.id == #userId")
public void deleteAccount(Long userId) {
userRepository.deleteById(userId);
}
Implementing an Ownership Check
The pattern “you can only operate on your own resources, but admins are an exception” comes up frequently.
@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 the expressions. However, writing SpEL directly is often sufficient at first.
Purpose and Use Cases of @PostAuthorize
@PostAuthorize is evaluated after the method executes. Use it when you want to use the return value (returnObject) in your 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 executed, DB access has occurred. If you use @PostAuthorize on processing with side effects (such as data updates), you’ll end up in a situation where the processing executed but was then denied, so as a rule, limit it to read-only operations.
Comparison with @Secured
@Secured is a legacy annotation that doesn’t support SpEL and just enumerates role strings.
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void someAdminOperation() {
// ...
}
Since Spring Security 6, @Secured has no functional advantage. For new projects, standardizing on @PreAuthorize is recommended. If you have @Secured in existing code, you can gradually replace it with @PreAuthorize whenever you happen to touch the code.
Behavior on Access Denial
When authorization fails, an AccessDeniedException is thrown, and by default HTTP 403 is returned. If unauthenticated (not logged in), an AuthenticationException returns 401, so they’re distinguished.
For JSON APIs, the default 403 response may be 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\"}");
};
}
Configure this in the exceptionHandling of SecurityFilterChain.
Testing with @WithMockUser
For testing method security, @WithMockUser from spring-security-test is convenient.
@SpringBootTest
class UserServiceTest {
@Autowired
private UserService userService;
@Test
@WithMockUser(roles = "ADMIN")
void admin_can_fetch_all_users() {
assertDoesNotThrow(() -> userService.findAll());
}
@Test
@WithMockUser(roles = "USER")
void regular_user_gets_403_on_fetching_all_users() {
assertThrows(AccessDeniedException.class, () -> userService.findAll());
}
}
@WithMockUser sets a user with the specified roles in Spring Security’s context. If you want to run the actual UserDetailsService, use @WithUserDetails.
Because method security operates through AOP, you need to test with actual Beans rather than mocks, using @SpringBootTest or @SpringBootTest(webEnvironment = NONE). If you’re curious about the basics of AOP, see Introduction to AOP in Spring Boot as a reference.
Summary
Here are the key points for introducing method security:
- Enable it by adding
@EnableMethodSecurityto your configuration class (Spring Security 6 and later) - Add
@PreAuthorizeto service methods to write role and permission checks - Using SpEL expressions to reference
authentication.nameand method arguments lets you write ownership checks concisely - Limit
@PostAuthorizeto control based on return values; don’t use it for processing with side effects - Don’t use
@Securedin new code; standardize on@PreAuthorize - Think of
SecurityFilterChainas the entrance for access control and method security as fine-grained control, with separated roles
If you’ve already implemented JWT authentication, RBAC will work just by enabling @EnableMethodSecurity and writing @PreAuthorize. For JWT authentication implementation, see How to Implement JWT Authentication in Spring Boot.