Spring Security Fundamentals

Method-Level Security

18 min Lesson 9 of 13

Method-Level Security

The SecurityFilterChain you configured in earlier lessons guards URL paths — it is your perimeter fence. But what happens when two different HTTP endpoints both call the same service method, or when an internal scheduled job invokes business logic that should still enforce access rules? URL-level authorization cannot answer that question. Method-level security moves the enforcement boundary into your service layer, where it belongs for anything beyond the simplest applications.

Spring Security provides two main annotations for this: @PreAuthorize and @Secured. This lesson covers both in depth — what they do, how they differ, when to prefer one over the other, and the security implications of each choice.

Enabling Method-Level Security

Method security is opt-in. Add @EnableMethodSecurity to any configuration class (your main application class or a dedicated security config class). In Spring Security 6 this annotation replaces the older @EnableGlobalMethodSecurity.

import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @Configuration @EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter public class MethodSecurityConfig { // no additional beans required for basic use }
Why opt-in? Enabling method security adds a Spring AOP proxy around every bean whose methods carry security annotations. If you accidentally annotate a bean that is not proxied through Spring (e.g. an object you new directly), the annotation is silently ignored. Opt-in keeps the behavior explicit and testable.

@PreAuthorize — Expression-Based, Fine-Grained Control

@PreAuthorize evaluates a Spring Expression Language (SpEL) expression before the method body runs. If the expression returns false, Spring throws AccessDeniedException and the method is never entered. This is the annotation you should reach for in nearly every new project.

import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; @Service public class ArticleService { // Only users with the ADMIN role may delete any article @PreAuthorize("hasRole('ADMIN')") public void deleteArticle(Long id) { articleRepository.deleteById(id); } // Users with EDITOR role OR the ADMIN role may publish @PreAuthorize("hasAnyRole('ADMIN', 'EDITOR')") public Article publish(Long id) { Article a = articleRepository.findById(id).orElseThrow(); a.setPublished(true); return articleRepository.save(a); } // A user may edit their own article, or an ADMIN may edit anyone's @PreAuthorize("hasRole('ADMIN') or #article.authorId == authentication.principal.id") public Article update(Article article) { return articleRepository.save(article); } }

The #article syntax in the third example is SpEL's way of referencing a method parameter by name. authentication.principal gives you the currently authenticated user object (your custom UserDetails implementation). This means your authorization logic can inspect real domain data — not just role strings — which is what makes @PreAuthorize so powerful.

Common SpEL Expressions

  • hasRole('ADMIN') — checks for ROLE_ADMIN in the user's granted authorities (Spring prepends ROLE_ automatically).
  • hasAnyRole('EDITOR','VIEWER') — true if the user has any of the listed roles.
  • hasAuthority('ARTICLE_DELETE') — checks the exact authority string; useful for fine-grained permission systems that go beyond role names.
  • isAuthenticated() — true for any logged-in user (not anonymous).
  • isAnonymous() — true only for unauthenticated requests.
  • authentication.name == 'system' — compare the username directly.
  • #param — reference a method parameter named param.
  • @myBean.check(#param) — delegate to a Spring bean method for complex logic.
Delegate complex rules to a bean. When your authorization logic involves a database lookup (e.g., "is this user a member of the project?"), put that logic in a Spring bean — say @Component SecurityService — and reference it in SpEL: @PreAuthorize("@securityService.isProjectMember(#projectId, authentication)"). This keeps your annotations readable and your logic unit-testable.

@PostAuthorize — Authorize on the Return Value

Sometimes you do not know whether access should be granted until after the method has loaded data from the database. @PostAuthorize runs its SpEL expression after the method returns, with the return value available as returnObject.

// Fetch the document first, then verify the caller owns it @PostAuthorize("returnObject.ownerId == authentication.principal.id or hasRole('ADMIN')") public Document getDocument(Long id) { return documentRepository.findById(id).orElseThrow(); }
@PostAuthorize still executes the method. The database read happens before the authorization check. For read-only operations this is often acceptable, but never use @PostAuthorize on a method that has side effects (writes, emails, payments) — use @PreAuthorize with a pre-loaded permission check instead.

@Secured — Simple Role Checking

@Secured is an older annotation that accepts a list of authority strings. The method executes only if the authenticated user holds at least one of them. Unlike @PreAuthorize, it does not support SpEL — no parameter references, no bean delegation, no compound expressions.

import org.springframework.security.access.annotation.Secured; @Service public class ReportService { @Secured("ROLE_ADMIN") public byte[] generateFullReport() { // ... } @Secured({"ROLE_ADMIN", "ROLE_AUDITOR"}) public List<AuditEntry> getAuditLog() { // ... } }

Notice the full ROLE_ prefix is required with @Secured, whereas hasRole() in SpEL adds it automatically.

To activate @Secured you must enable it explicitly:

@EnableMethodSecurity(securedEnabled = true) // also enables @PreAuthorize by default public class MethodSecurityConfig { }

@PreAuthorize vs @Secured — When to Use Which

  • Use @PreAuthorize for all new code. It is more expressive, supports SpEL, works with both roles and fine-grained authorities, and can inspect method arguments.
  • Use @Secured only when you need to support a legacy codebase that already uses it, or when you want a visual signal that a method performs only a simple role check and nothing more complex.

Applying Annotations at the Class Level

You can place @PreAuthorize on a class to set a default policy for all its methods, then override it per-method where needed:

@Service @PreAuthorize("isAuthenticated()") // every method requires login public class InvoiceService { public List<Invoice> listMyInvoices() { /* ... */ } // inherits isAuthenticated() @PreAuthorize("hasRole('ADMIN')") // overrides: only ADMIN here public void deleteInvoice(Long id) { /* ... */ } }

Testing Method Security

Spring Security Test provides @WithMockUser and @WithUserDetails to simulate authenticated contexts in unit tests. This lets you verify authorization rules without a running server.

import org.springframework.security.test.context.support.WithMockUser; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest class ArticleServiceTest { @Autowired ArticleService articleService; @Test @WithMockUser(roles = "ADMIN") void adminCanDelete() { assertDoesNotThrow(() -> articleService.deleteArticle(1L)); } @Test @WithMockUser(roles = "EDITOR") void editorCannotDelete() { assertThrows(org.springframework.security.access.AccessDeniedException.class, () -> articleService.deleteArticle(1L)); } }
Test your negative cases. A missing @PreAuthorize annotation is a silent security hole. Always write tests that confirm unauthorized users are rejected, not just that authorized users are permitted.

Practical Pitfalls

  • Self-invocation bypasses the proxy. If a method in the same class calls another annotated method directly (not through the Spring proxy), the annotation is not evaluated. Extract the method to a separate bean if you need it enforced on internal calls.
  • Interface vs. implementation. Annotate the implementation class methods, not the interface methods, unless you are using interface-based proxies (unusual in Spring Boot). Spring Security 6 supports both, but placing annotations on the implementation is safer and more visible.
  • Don't replace URL security. Method security complements URL authorization — it does not replace it. Keep both layers. URL rules reject bad requests at the perimeter; method rules enforce business invariants deep in the stack.

Summary

Method-level security lets you attach authorization rules directly to your service methods using @PreAuthorize (SpEL, expressive, preferred) or @Secured (simple role strings, legacy-compatible). Enable the feature with @EnableMethodSecurity on a configuration class. Use SpEL parameter references and bean delegation to keep complex rules testable. Always pair method security with URL authorization — the two layers together give you defense in depth. In the next lesson you will put everything together by securing a complete web application end-to-end.