Spring Boot – Method-Level Security

July 31, 20265 min readUpdated 8/18/2026

URL rules protect an entry point. Method-level security protects a method, which is a different thing — and the difference starts to matter the moment two entry points can reach the same service.

Turning it on

@Configuration
@EnableWebSecurity
// Turns on @PreAuthorize / @PostAuthorize. Defence in depth: the URL rules below guard the
// HTTP entry points, and the annotations guard the service methods regardless of which entry
// point reached them. A new controller that forgets its URL rule is still refused.
@EnableMethodSecurity
public class SecurityConfig { }

Without @EnableMethodSecurity, every annotation below is inert. No error, no warning — the same silent failure as @EnableCaching (lesson 21), and just as easy to miss because everything appears to work.

In older code you may see @EnableGlobalMethodSecurity(prePostEnabled = true). That is deprecated; @EnableMethodSecurity enables the pre/post annotations by default.

The three annotations

AnnotationRunsExpression language
@PreAuthorizebefore the methodSpEL — the useful one
@PostAuthorizeafter, can see the resultSpEL
@Securedbeforerole names only — legacy

@Secured("ROLE_ADMIN") is the oldest and can only check role names, with the ROLE_ prefix spelled out. @PreAuthorize does everything it does and more. There is no reason to choose @Secured in new code.

In practice

/**
 * <p>Every method here carries {@code @PreAuthorize("hasRole('ADMIN')")} even though
 * {@code SecurityConfig} already restricts {@code /api/admin/**} to admins. That is not
 * belt-and-braces for its own sake: URL rules protect one entry point, and this service is a bean
 * that any future controller, scheduled job or message listener can inject. The annotation travels
 * with the method, so the guard cannot be left behind when the caller changes.
 */
@Service
@Slf4j
public class AdminUserServiceImpl implements AdminUserService {

    @Override
    @PreAuthorize("hasRole('ADMIN')")
    @Transactional(readOnly = true)
    public List<AdminUserDTO> getAllUsers() {
        return userDAO.findAllForAdmin();
    }

    @Override
    @PreAuthorize("hasRole('ADMIN')")
    @Transactional
    public AdminUserDTO changeRole(String actingAdminEmail, UUID userId, UserRole role) { /* … */ }

    @Override
    @PreAuthorize("hasRole('ADMIN')")
    @Transactional
    public void deleteUser(String actingAdminEmail, UUID userId) { /* … */ }
}

That comment is the argument for the whole feature. The URL rule /api/admin/** → hasRole("ADMIN") already covers today's only caller. But AdminUserService is a bean, and a message listener or a scheduled job added next year can inject it — reaching the method by a path that has no URL and therefore no URL rule.

Expressions

// Roles and authorities
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
@PreAuthorize("hasAuthority('ORDER_REFUND')")

// Just authenticated
@PreAuthorize("isAuthenticated()")

// Combined
@PreAuthorize("hasRole('ADMIN') and #order.total < 1000")

// Against the caller's own identity — the interesting case
@PreAuthorize("#email == authentication.name")
public UserDTO getProfile(String email) { }

// Delegate to a bean, for anything non-trivial
@PreAuthorize("@orderSecurity.isOwner(#orderId, authentication.name)")
public OrderDTO getOrder(UUID orderId) { }

#name refers to a method parameter, authentication to the current Authentication, and @beanName calls a Spring bean.

That last form is the one to reach for. An expression more complicated than a role check belongs in a bean, where it can be read, unit-tested and reused. Ownership logic written as a SpEL string is untestable and fails at runtime rather than compile time.

⚠️ hasRole('ADMIN') checks for the authority ROLE_ADMIN. hasAuthority('ADMIN') checks for ADMIN. Same trap as lesson 23, in a different place.

@PostAuthorize, and why it is rarely right

@PostAuthorize("returnObject.email == authentication.name")
public UserDTO getUser(UUID id) { }

It runs after the method and can inspect returnObject. Useful when ownership is only knowable once you have loaded the thing.

Two problems. The work is already done — you queried the database and built the DTO, then threw it away. And it does not undo side effects: on a method that writes, the write has happened and only the response is refused. Never put @PostAuthorize on something that mutates state.

Where you can, prefer designing the ownership question away. The pizza API's approach is stronger than any annotation:

/api/me/** resolves the owner from the token — no user id in the path.

If the caller cannot name a resource that is not theirs, there is nothing to authorize.

⚠️ The proxy rule, one more time

/**
 * <p>⚠️ Being proxy-based, it has the same blind spot as {@code @Transactional} and
 * {@code @Cacheable}: a call from another method inside THIS class skips the check entirely.
 */
@Service
public class AdminUserServiceImpl {

    public void bulkDelete(List<UUID> ids) {
        ids.forEach(id -> deleteUser(admin, id));   // NO authorization check runs
    }

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteUser(String actingAdminEmail, UUID userId) { }
}

Lesson 8 covers why. It is worth repeating here because the consequence is worse: with @Transactional a self-invocation costs you a transaction, and with @PreAuthorize it costs you the security check entirely — silently. Any public method that loops over an annotated one needs its own annotation.

The same applies to private and final methods: a proxy cannot override them, so annotations on them never run.

Rules that annotations cannot express

Some authorization is business logic and belongs in the method:

Admins cannot demote or delete themselves — that would lock the last admin out.

That is why changeRole and deleteUser take the acting admin's email as a parameter as well as the target. No role check can express "not yourself"; it is a rule about the relationship between two arguments, and it lives in the method body where it can be tested directly.

Use annotations for "may this kind of user call this at all", and code for rules about the specific data.

What it returns

A failed check throws AccessDeniedException, which the pizza API's exception handler turns into a clean 403:

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(
        AccessDeniedException ex, HttpServletRequest request) {
    ApiError error = new ApiError(
            HttpStatus.FORBIDDEN,
            "You do not have permission to do that",
            request.getRequestURI());
    return ResponseEntity.status(error.getStatus()).body(error);
}

Remember lesson 12's caveat: 403 confirms the resource exists. For a resource that simply is not the caller's, 404 leaks less.

What to take from this

  • @EnableMethodSecurity, or every annotation is silently inert.
  • @PreAuthorize over @Secured — same job, more capable.
  • URL rules and method annotations do different jobs. Use both.
  • Delegate real logic to a bean rather than growing a SpEL string.
  • A self-invocation skips the check entirely.

Next: OAuth2 — and the reconciliation step every tutorial leaves out.