Spring BootMethod Level Security
Spring Boot

Method Level Security

Method level security enforces authorization at the service layer using annotations on individual methods. It complements URL-based security by applying fine-grained access control that is independent of the HTTP layer — a service method can be secured regardless of how it is called. Spring Security provides @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter, @Secured, and @RolesAllowed.

Enabling Method Security

Method security is not active by default. Add @EnableMethodSecurity to a @Configuration class to activate it. Spring Security 6 deprecates the older @EnableGlobalMethodSecurity in favour of @EnableMethodSecurity, which enables @PreAuthorize and @PostAuthorize by default.
Java
// ── Enable method security (Spring Security 6): ──────────────────────
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(
    prePostEnabled = true,    // @PreAuthorize, @PostAuthorize (default: true)
    securedEnabled = true,    // @Secured (default: false)
    jsr250Enabled = true      // @RolesAllowed, @PermitAll, @DenyAll (default: false)
)
public class SecurityConfig {
    // ...
}

// ── Legacy: @EnableGlobalMethodSecurity (Spring Security 5 — deprecated):
@EnableGlobalMethodSecurity(
    prePostEnabled = true,
    securedEnabled = true,
    jsr250Enabled = true
)
// Use @EnableMethodSecurity for new projects — identical functionality,
// cleaner API, supports proxy-less AOP.

// ── How method security works internally: ─────────────────────────────
// Spring wraps each @Service/@Component bean in a proxy.
// When a secured method is called, the proxy intercepts the call,
// evaluates the security expression, and either:
//   a) allows the call through to the real method, or
//   b) throws AccessDeniedException (→ HTTP 403)
//
// IMPORTANT: method security only works for calls through the proxy.
// A method calling another method on the SAME bean bypasses the proxy.
// Self-invocation does not trigger method security annotations.

// ── Self-invocation problem: ──────────────────────────────────────────
@Service
public class OrderService {

    @PreAuthorize("hasRole('ADMIN')")
    public List<Order> findAllOrders() { ... }

    public void processOrders() {
        List<Order> orders = this.findAllOrders();  // BYPASSES @PreAuthorize!
        // 'this' is the real bean, not the proxy.
        // Solution: inject the bean into itself, or refactor to separate beans.
    }
}

@PreAuthorize — Before Method Execution

@PreAuthorize evaluates a Spring Expression Language (SpEL) expression before the method executes. If the expression evaluates to false, AccessDeniedException is thrown and the method body never runs. It has access to the security context, method parameters, and Spring beans.
Java
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // ── Simple role check: ────────────────────────────────────────────
    @PreAuthorize("hasRole('ADMIN')")
    public List<UserResponse> findAll() {
        return userRepository.findAll().stream()
            .map(UserResponse::from).toList();
    }

    // ── Authority check: ──────────────────────────────────────────────
    @PreAuthorize("hasAuthority('user:read')")
    public UserResponse findById(Long id) {
        return UserResponse.from(
            userRepository.findById(id).orElseThrow());
    }

    // ── Current user owns the resource (#id matches principal.id): ────
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    public UserResponse findByIdOwnerOrAdmin(Long id) {
        return UserResponse.from(
            userRepository.findById(id).orElseThrow());
    }

    // ── Access request object fields via #paramName.fieldName: ────────
    @PreAuthorize("hasRole('ADMIN') or " +
                  "#request.email == authentication.principal.username")
    public UserResponse update(Long id, UpdateUserRequest request) {
        // Only admins or the user themselves can update
        return UserResponse.from(userService.update(id, request));
    }

    // ── Multiple conditions: ──────────────────────────────────────────
    @PreAuthorize("hasAuthority('user:write') and " +
                  "#user.role.name() != 'ADMIN'")
    public UserResponse createNonAdmin(CreateUserRequest user) {
        return UserResponse.from(userService.create(user));
    }

    // ── Spring bean method call in SpEL (@beanName.method(args)): ────
    @PreAuthorize("@permissionEvaluator.canEditUser(authentication, #id)")
    public UserResponse editById(Long id) {
        return UserResponse.from(
            userRepository.findById(id).orElseThrow());
    }

    // ── isAuthenticated / isAnonymous: ────────────────────────────────
    @PreAuthorize("isAuthenticated()")
    public UserResponse getMyProfile(
            @AuthenticationPrincipal UserDetails currentUser) {
        return UserResponse.from(
            userRepository.findByUsername(
                currentUser.getUsername()).orElseThrow());
    }

    // ── Deny all — useful on base class methods: ──────────────────────
    @PreAuthorize("denyAll()")
    public void internalOnlyMethod() { }
}

@PostAuthorize and @PreFilter / @PostFilter

@PostAuthorize evaluates after the method returns and can inspect the returned object via returnObject. @PreFilter and @PostFilter filter collection parameters and return values element-by-element.
Java
@Service
public class DocumentService {

    // ── @PostAuthorize — verify the returned object after execution: ──
    // The method runs first, then ownership is checked.
    // If false: AccessDeniedException is thrown (response not returned).
    @PostAuthorize("returnObject.ownerUsername == authentication.name " +
                   "or hasRole('ADMIN')")
    public Document findById(Long id) {
        return documentRepository.findById(id).orElseThrow();
        // The SELECT executes, then the returned Document's owner is checked.
        // Use @PreAuthorize when possible — it avoids unnecessary DB calls.
    }

    // ── @PostFilter — filter returned collection in memory: ───────────
    // filterObject refers to each element of the returned collection.
    // Elements where the expression is false are removed from the result.
    @PostFilter("filterObject.ownerUsername == authentication.name " +
                "or hasRole('ADMIN')")
    public List<Document> findAll() {
        return documentRepository.findAll();
        // ALL documents loaded from DB, then filtered in Java.
        // WARNING: inefficient for large sets — filter in the query instead.
    }

    // ── @PreFilter — filter input collection before execution: ────────
    // filterObject refers to each element of the annotated parameter.
    // Only elements where the expression is true are passed to the method.
    @PreFilter(value = "filterObject.ownerUsername == authentication.name",
               filterTarget = "documents")   // required when > 1 parameter
    public void processDocuments(List<Document> documents, String action) {
        // 'documents' list has been filtered — only caller's own documents remain.
        documents.forEach(doc -> process(doc, action));
    }

    // ── Combining @PreAuthorize and @PostFilter: ──────────────────────
    @PreAuthorize("isAuthenticated()")
    @PostFilter("filterObject.visibility == 'PUBLIC' " +
                "or filterObject.ownerUsername == authentication.name")
    public List<Document> findByTag(String tag) {
        return documentRepository.findByTag(tag);
        // Authenticated users see: their own docs + all public docs.
    }
}

@Secured and @RolesAllowed

@Secured and @RolesAllowed are simpler alternatives to @PreAuthorize that only support role checks with no SpEL expressions. @Secured is Spring's annotation; @RolesAllowed is from the JSR-250 standard.
Java
@Service
public class AdminService {

    // ── @Secured — Spring's annotation (securedEnabled = true required): ─
    @Secured("ROLE_ADMIN")
    public void performAdminAction() { ... }

    @Secured({"ROLE_ADMIN", "ROLE_MODERATOR"})
    public void performModeratorAction() { ... }

    // ── @RolesAllowed — JSR-250 standard (jsr250Enabled = true required): ─
    @RolesAllowed("ADMIN")     // no ROLE_ prefix needed
    public void jsr250AdminAction() { ... }

    @RolesAllowed({"ADMIN", "MODERATOR"})
    public void jsr250ModeratorAction() { ... }

    // ── JSR-250 @PermitAll and @DenyAll: ──────────────────────────────
    @PermitAll
    public void publicAction() { ... }

    @DenyAll
    public void internalMethod() { ... }

    // ── @Secured vs @PreAuthorize: ────────────────────────────────────
    // @Secured("ROLE_ADMIN")
    // @PreAuthorize("hasRole('ADMIN')")     — identical result

    // @Secured cannot:
    //   - Access method parameters
    //   - Use SpEL expressions
    //   - Call Spring beans
    //   - Check authorities (only roles)

    // Use @PreAuthorize for everything — it is the most capable annotation
    // and the only one needed for production code.
    // @Secured and @RolesAllowed exist for compatibility and simplicity.
}

Custom Permission Evaluator

A PermissionEvaluator provides domain-object-level security — evaluating whether the current user has a specific permission on a specific object. It is invoked via the hasPermission() expression in @PreAuthorize.
Java
// ── PermissionEvaluator implementation: ──────────────────────────────
@Component
@RequiredArgsConstructor
public class AppPermissionEvaluator implements PermissionEvaluator {

    private final DocumentRepository documentRepository;
    private final OrderRepository orderRepository;

    @Override
    public boolean hasPermission(Authentication auth,
            Object targetDomainObject, Object permission) {
        if (auth == null || targetDomainObject == null) return false;
        String username = auth.getName();
        String perm = permission.toString();

        if (targetDomainObject instanceof Document doc) {
            return switch (perm) {
                case "READ"   -> doc.isPublic()
                               || doc.getOwnerUsername().equals(username);
                case "WRITE"  -> doc.getOwnerUsername().equals(username)
                               || hasRole(auth, "ADMIN");
                case "DELETE" -> doc.getOwnerUsername().equals(username)
                               || hasRole(auth, "ADMIN");
                default       -> false;
            };
        }
        return false;
    }

    @Override
    public boolean hasPermission(Authentication auth,
            Serializable targetId, String targetType, Object permission) {
        if (auth == null) return false;
        String username = auth.getName();

        return switch (targetType) {
            case "Document" -> {
                Document doc = documentRepository
                    .findById((Long) targetId).orElse(null);
                yield doc != null && hasPermission(auth, doc, permission);
            }
            case "Order" -> {
                Order order = orderRepository
                    .findById((Long) targetId).orElse(null);
                yield order != null
                    && order.getCustomerUsername().equals(username);
            }
            default -> false;
        };
    }

    private boolean hasRole(Authentication auth, String role) {
        return auth.getAuthorities().stream()
            .anyMatch(a -> a.getAuthority().equals("ROLE_" + role));
    }
}

// ── Register with Spring Security: ───────────────────────────────────
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {

    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
            AppPermissionEvaluator permissionEvaluator) {
        DefaultMethodSecurityExpressionHandler handler =
            new DefaultMethodSecurityExpressionHandler();
        handler.setPermissionEvaluator(permissionEvaluator);
        return handler;
    }
}

// ── Use hasPermission() in @PreAuthorize: ─────────────────────────────
@Service
public class DocumentService {

    // Check permission on an object already loaded:
    @PreAuthorize("hasPermission(#doc, 'WRITE')")
    public Document save(Document doc) { ... }

    // Check permission by ID and type (lazy — document not loaded yet):
    @PreAuthorize("hasPermission(#id, 'Document', 'READ')")
    public Document findById(Long id) {
        return documentRepository.findById(id).orElseThrow();
    }

    @PreAuthorize("hasPermission(#id, 'Document', 'DELETE')")
    public void delete(Long id) {
        documentRepository.deleteById(id);
    }
}

Testing Method Security

Method security annotations must be tested with the correct Spring Security test support. @WithMockUser and @WithUserDetails inject a mock authentication into the SecurityContext for the duration of a test.
XML
<!-- pom.xml: -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

@SpringBootTest
class UserServiceSecurityTest {

    @Autowired
    private UserService userService;

    // ── @WithMockUser — simple role mock: ─────────────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void findAll_allowedForAdmin() {
        assertThatNoException().isThrownBy(() -> userService.findAll());
    }

    @Test
    @WithMockUser(roles = "USER")
    void findAll_deniedForUser() {
        assertThatThrownBy(() -> userService.findAll())
            .isInstanceOf(AccessDeniedException.class);
    }

    // ── @WithMockUser with authorities: ──────────────────────────────
    @Test
    @WithMockUser(username = "alice",
                  authorities = {"user:read", "ROLE_USER"})
    void findById_allowedWithReadAuthority() {
        assertThatNoException().isThrownBy(
            () -> userService.findById(1L));
    }

    // ── @WithUserDetails — loads a real UserDetails from the context: ─
    @Test
    @WithUserDetails("alice@example.com")  // loaded via UserDetailsService
    void getMyProfile_returnsCurrentUser() {
        UserResponse profile = userService.getMyProfile(null);
        assertThat(profile.email()).isEqualTo("alice@example.com");
    }

    // ── Unauthenticated — no annotation: ─────────────────────────────
    @Test
    void findAll_deniedForAnonymous() {
        // No @WithMockUser — SecurityContext is empty:
        assertThatThrownBy(() -> userService.findAll())
            .isInstanceOf(AuthenticationCredentialsNotFoundException.class);
    }

    // ── SecurityMockMvcRequestPostProcessors for MockMvc: ─────────────
    @Autowired
    private MockMvc mockMvc;

    @Test
    void getUsers_withMockAdmin() throws Exception {
        mockMvc.perform(get("/api/users")
                .with(user("admin").roles("ADMIN")))
            .andExpect(status().isOk());
    }

    @Test
    void getUsers_withJwt() throws Exception {
        mockMvc.perform(get("/api/users")
                .with(jwt().authorities(
                    new SimpleGrantedAuthority("ROLE_ADMIN"))))
            .andExpect(status().isOk());
    }
}