Method Security: @PreAuthorize, @PostAuthorize, @Secured

Why Method Security Exists

URL-based authorization in authorizeHttpRequests() protects HTTP endpoints, but it has blind spots:

  • The same service method may be called from multiple controllers, scheduled tasks, or message listeners — none of which pass through the HTTP filter chain
  • Fine-grained rules based on method arguments or return values cannot be expressed as URL patterns
  • Authorization logic spread across a large security config is hard to read alongside the code it protects

Method security moves the authorization decision to the method itself. A @PreAuthorize annotation on a service method is enforced regardless of how that method is called.


Enabling Method Security

Add @EnableMethodSecurity to any @Configuration class:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // enables @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter
public class SecurityConfig {
    // ...
}

@EnableMethodSecurity replaces the older @EnableGlobalMethodSecurity (deprecated in Spring Security 6). By default it enables pre/post annotations. To also enable @Secured and JSR-250 annotations (@RolesAllowed):

@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)

@PreAuthorize

@PreAuthorize evaluates a SpEL expression before the method executes. If the expression is false, a AccessDeniedException is thrown and the method body never runs.

@Service
public class UserService {

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

    @PreAuthorize("hasAuthority('user:read')")
    public User getUserById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User not found"));
    }

    @PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
    public User getUserByUsername(String username) {
        return userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(username));
    }
}

The third example shows the key power: #username binds to the method parameter username, and authentication.name is the currently logged-in user’s name. Users can read their own profile; admins can read anyone’s.

Accessing Method Parameters in SpEL

Parameter names are available via #paramName syntax. Spring resolves them using reflection with -parameters compiler flag or @P annotation as a fallback:

// Works when compiled with -parameters (Spring Boot default)
@PreAuthorize("hasRole('MANAGER') or #userId == authentication.principal.id")
public void updateUser(Long userId, UserUpdateRequest request) { ... }

// Explicit parameter name binding with @P
@PreAuthorize("hasRole('MANAGER') or #id == authentication.principal.id")
public void updateUser(@P("id") Long userId, UserUpdateRequest request) { ... }

Checking Object Properties

If the parameter is an object, you can navigate its properties:

@PreAuthorize("hasRole('ADMIN') or #order.customerId == authentication.principal.id")
public void cancelOrder(Order order) { ... }

@PostAuthorize

@PostAuthorize evaluates after the method returns, with access to the return value via returnObject. Use it when the access decision depends on what the method returned.

@Service
public class DocumentService {

    @PostAuthorize("hasRole('ADMIN') or returnObject.ownerId == authentication.principal.id")
    public Document getDocument(Long id) {
        return documentRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Document not found"));
    }
}

The method executes and loads the document from the database. The authorization check then verifies whether the caller is the document owner. If not, AccessDeniedException is thrown — but note that the method did execute (and any side effects occurred). Use @PostAuthorize only for read operations or when pre-execution side effects are acceptable.


@PreFilter and @PostFilter

These filter collection arguments or return values rather than blocking the entire call.

@PostFilter

Filters the returned collection, keeping only elements for which the expression is true:

@PostFilter("hasRole('ADMIN') or filterObject.ownerId == authentication.principal.id")
public List<Document> getAllDocuments() {
    return documentRepository.findAll();  // loads all, then filters in memory
}

filterObject refers to each element in the collection. After the method returns, Spring removes any element for which the expression evaluates to false.

Warning: @PostFilter loads the full dataset and filters in memory. For large collections, filter in the query instead:

// Prefer this — filter in the database
@PreAuthorize("isAuthenticated()")
public List<Document> getDocuments(Authentication auth) {
    if (hasRole(auth, "ADMIN")) return documentRepository.findAll();
    return documentRepository.findByOwnerId(auth.getName());
}

@PreFilter

Filters a collection argument before the method executes:

@PreFilter("filterObject.ownerId == authentication.principal.id")
public void deleteDocuments(List<Document> documents) {
    documentRepository.deleteAll(documents);  // only receives documents the caller owns
}

When a method has multiple collection parameters, specify which one to filter:

@PreFilter(filterTarget = "documents", value = "filterObject.status == 'DRAFT'")
public void publishDocuments(List<Document> documents, PublishConfig config) { ... }

@Secured

@Secured is simpler than @PreAuthorize — it takes a list of role strings and requires the user to have at least one of them. No SpEL support.

@Secured("ROLE_ADMIN")
public void deleteUser(Long id) { ... }

@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public List<Report> getReports() { ... }

Enable with @EnableMethodSecurity(securedEnabled = true). Prefer @PreAuthorize for new code — @Secured exists mainly for compatibility.


@RolesAllowed (JSR-250)

The standard Java EE / Jakarta EE annotation. Functionally equivalent to @Secured:

@RolesAllowed("ROLE_ADMIN")
public void deleteUser(Long id) { ... }

@RolesAllowed({"ROLE_ADMIN", "ROLE_MANAGER"})
public List<Report> getReports() { ... }

Enable with @EnableMethodSecurity(jsr250Enabled = true). Useful if you want to keep your service layer free of Spring-specific annotations.


Built-in SpEL Expressions

Spring Security provides these built-in expressions for use in @PreAuthorize and @PostAuthorize:

ExpressionDescription
hasRole('X')User has ROLE_X
hasAnyRole('X', 'Y')User has ROLE_X or ROLE_Y
hasAuthority('X')User has authority X (exact match)
hasAnyAuthority('X', 'Y')User has X or Y
isAuthenticated()User is logged in (not anonymous)
isAnonymous()User is anonymous
isFullyAuthenticated()Authenticated and not via remember-me
isRememberMe()Authenticated via remember-me token
permitAllAlways true
denyAllAlways false
authenticationThe current Authentication object
principalauthentication.getPrincipal()

Custom SpEL Beans

For complex authorization logic, delegate to a Spring bean from SpEL using the @beanName.method() syntax:

@Component("authz")
public class AuthorizationHelper {

    private final ProjectRepository projectRepository;

    public AuthorizationHelper(ProjectRepository projectRepository) {
        this.projectRepository = projectRepository;
    }

    public boolean isProjectMember(Authentication auth, Long projectId) {
        return projectRepository.isMember(auth.getName(), projectId);
    }

    public boolean isProjectOwner(Authentication auth, Long projectId) {
        return projectRepository.isOwner(auth.getName(), projectId);
    }
}
@Service
public class ProjectService {

    @PreAuthorize("hasRole('ADMIN') or @authz.isProjectMember(authentication, #projectId)")
    public Project getProject(Long projectId) { ... }

    @PreAuthorize("hasRole('ADMIN') or @authz.isProjectOwner(authentication, #projectId)")
    public void deleteProject(Long projectId) { ... }
}

This keeps complex database lookups out of annotation strings and makes authorization logic unit-testable.


Custom Meta-Annotations

Repeating @PreAuthorize("hasRole('ADMIN')") across dozens of methods is noisy. Create custom meta-annotations:

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

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public @interface IsSelfOrAdmin {
    // Can't parameterize SpEL from meta-annotations — use fixed param name convention
}

Usage:

@IsAdmin
public void deleteUser(Long id) { ... }

@IsAdmin
public List<User> getAllUsers() { ... }

You can also annotate at the class level — every method in the class inherits the constraint:

@RestController
@PreAuthorize("hasRole('ADMIN')")  // all methods require ADMIN
@RequestMapping("/admin")
public class AdminController {

    public List<User> listUsers() { ... }  // inherits ADMIN requirement

    @PreAuthorize("hasRole('SUPER_ADMIN')")  // method annotation overrides class annotation
    public void nukeDatabase() { ... }
}

Handling AccessDeniedException

When @PreAuthorize denies access, Spring throws AccessDeniedException. For REST APIs, handle it globally:

@RestControllerAdvice
public class SecurityExceptionHandler {

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .body(new ErrorResponse("Access denied", ex.getMessage()));
    }
}

Spring Security’s ExceptionTranslationFilter also handles AccessDeniedException for requests that pass through the filter chain — for non-HTTP invocations (scheduled jobs, message listeners), the exception propagates up to your calling code.


Testing Method Security

Use @WithMockUser to simulate a logged-in user in tests:

@SpringBootTest
class UserServiceTest {

    @Autowired
    private UserService userService;

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

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

    @Test
    @WithMockUser(username = "alice", roles = "USER")
    void userCanReadOwnProfile() {
        assertDoesNotThrow(() -> userService.getUserByUsername("alice"));
    }

    @Test
    @WithMockUser(username = "alice", roles = "USER")
    void userCannotReadOtherProfile() {
        assertThrows(AccessDeniedException.class,
            () -> userService.getUserByUsername("bob"));
    }
}

Method Security Internals

Method security is implemented via Spring AOP. When @EnableMethodSecurity is active, Spring wraps annotated beans in a proxy. Every call to an annotated method goes through AuthorizationManagerBeforeMethodInterceptor (for @PreAuthorize) and AuthorizationManagerAfterMethodInterceptor (for @PostAuthorize).

flowchart LR
    Caller --> Proxy[Spring AOP Proxy]
    Proxy --> Pre[PreAuthorize\nInterceptor]
    Pre -->|Denied| Ex[AccessDeniedException]
    Pre -->|Allowed| Method[Actual Method]
    Method --> Post[PostAuthorize\nInterceptor]
    Post -->|Denied| Ex
    Post -->|Allowed| Caller

Because this is proxy-based, self-invocation does not trigger security checks:

@Service
public class OrderService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteOrder(Long id) { ... }

    public void processOrders() {
        deleteOrder(1L);  // BYPASSES @PreAuthorize — same class, no proxy involved
    }
}

Fix by injecting the bean into itself (or extracting to a separate class):

@Service
public class OrderService {

    @Autowired
    private OrderService self;  // inject own proxy

    public void processOrders() {
        self.deleteOrder(1L);  // goes through proxy — @PreAuthorize is enforced
    }
}

Key Takeaways

  • @EnableMethodSecurity activates method-level authorization across the entire application
  • @PreAuthorize is the primary annotation — it runs before the method and supports full SpEL
  • @PostAuthorize runs after the method and can inspect the return value — use only for reads
  • @PreFilter / @PostFilter filter collections rather than blocking the call entirely
  • Delegate to @Component beans from SpEL for complex, testable authorization logic
  • Method security uses AOP proxies — self-invocations within the same bean bypass all annotations
  • Class-level annotations apply to all methods; method annotations override class annotations

Next: Domain Object Security: Access Control Lists (ACLs) — per-object permissions for fine-grained data-level access control.