Skip to content
Chapter 42 · Spring Boot

Spring Security Part 9: Method Security with @PreAuthorize and @PostAuthorize ​

So far, authorization lived in the security filter chain:

java
http
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/users").hasRole("USER")
        .anyRequest().authenticated())
    .formLogin(Customizer.withDefaults());

With hundreds of APIs, one long list of URL rules in the security configuration becomes hard to maintain. Annotation-based authorization puts the rule on the method itself, next to the code it protects.

Users with roles and permissions ​

A user gets one role (ROLE_USER, ROLE_ADMIN) and any number of fine-grained permissions (ORDER_READ, SALES_DELETE, ...). Both end up as authorities:

java
@Entity
@Table(name = "user_login")
public class UserLoginEntity implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;

    private String role;   // e.g. "ROLE_USER" or "ROLE_ADMIN"

    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
    private List<UserPermissionEntity> permissions = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Set<GrantedAuthority> authorities = new HashSet<>();
        authorities.add(new SimpleGrantedAuthority(role));
        permissions.forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission.getName())));
        return authorities;
    }

    public Long getId() { return id; }
    @Override public String getPassword() { return password; }
    @Override public String getUsername() { return username; }
    // setters
}
java
@Entity
@Table(name = "user_permission")
public class UserPermissionEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;   // e.g. ORDER_READ, SALES_READ

    // getters and setters
}

The repository, the service implementing UserDetailsService, and a registration endpoint (POST /user-login, hashing the password) are as in part 2.

Enabling method security ​

java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity   // without it, @PreAuthorize and @PostAuthorize are ignored
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers("/user-login").permitAll()
                .anyRequest().authenticated())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

@EnableMethodSecurity turns on @PreAuthorize and @PostAuthorize by default (prePostEnabled = true is the default, so writing it is optional). The filter chain still authenticates; the fine-grained checks move to the methods.

@PreAuthorize ​

Checked before the method runs:

java
@RestController
@RequestMapping("/api")
public class OrderController {

    @GetMapping("/orders")
    @PreAuthorize("hasRole('USER') and hasAuthority('ORDER_READ')")
    public ResponseEntity<String> readOrders() {
        return ResponseEntity.ok("ALL orders has been fetched successfully");
    }
}
java
@RestController
@RequestMapping("/api")
public class SalesController {

    @GetMapping("/sales")
    @PreAuthorize("hasAuthority('SALES_READ')")
    public ResponseEntity<String> readSalesDetails() {
        return ResponseEntity.ok("ALL Sales details has been fetched successfully");
    }
}
User's authorities/api/orders/api/sales
ROLE_USER, ORDER_READ200403
ROLE_USER, ORDER_READ, SALES_READ200200
ORDER_READ only (no role)403403

hasRole vs hasAuthority ​

Both check the user's authorities. The only difference: hasRole adds the ROLE_ prefix.

java
// SecurityExpressionRoot, simplified
public final boolean hasAuthority(String authority) {
    return hasAnyAuthorityName(null, authority);                    // no prefix
}

public final boolean hasRole(String role) {
    return hasAnyAuthorityName(this.defaultRolePrefix, role);       // "ROLE_" + role
}

private boolean hasAnyAuthorityName(String prefix, String... roles) {
    Set<String> roleSet = getAuthoritySet();
    for (String role : roles) {
        String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
        if (roleSet.contains(defaultedRole)) {
            return true;
        }
    }
    return false;
}

So hasRole('USER') is the same as hasAuthority('ROLE_USER').

How the check runs ​

The annotation is enforced with AOP: @EnableMethodSecurity wraps the bean in a proxy (the controller above is an AOP proxy), and AuthorizationManagerBeforeMethodInterceptor, an AOP method interceptor, evaluates the expression before calling the real method. A failed check throws AccessDeniedException, which becomes 403. These are method interceptors (the AOP chapter), not Spring MVC HandlerInterceptors, so @PreAuthorize works on any Spring bean, a service as well as a controller. The usual proxy rules apply: a call from inside the same class skips the check.

The expression is SpEL (Spring Expression Language), parsed by Spring's SpelExpressionParser.

Logical operators:

OperatorMeaningExample
andbothhasRole('ADMIN') and hasAuthority('READ')
oreitherhasRole('ADMIN') or hasRole('USER')
notnegationnot hasRole('ADMIN')
!negation!hasAuthority('DELETE')

Relational operators: ==, !=, <, >, <=, >=, as in #value >= 90.

Method parameters are available as #name, and the logged-in user as authentication:

java
@PreAuthorize("#id == authentication.principal.id")
@GetMapping("/users/{id}")
public User fetchUserDetails(@PathVariable Long id) {
    return userServiceObject.fetchUserDetails(id);
}

A user can fetch only their own details: user 11 gets 200 for /users/11 and 403 for /users/12. (authentication.principal is the UserDetails object, so it needs a getId().)

@PostAuthorize ​

Checked after the method has run, but before the response goes back. Handled by AuthorizationManagerAfterMethodInterceptor. The return value is available as returnObject:

java
@GetMapping("/orders")
@PreAuthorize("hasRole('USER') and hasAuthority('ORDER_READ')")
@PostAuthorize("returnObject.userID == authentication.principal.id")
public OrderDTO readOrders() {
    OrderDTO orderDTO = new OrderDTO();
    orderDTO.userID = 11;       // hardcoded for testing: the id of "a_user"
    orderDTO.orderID = 100001;
    return orderDTO;
}
  • a_user (id 11): 200 with the order.
  • b_user (id 12): passes @PreAuthorize, the method runs, then @PostAuthorize fails: 403, and the order is not sent.

Because the check comes afterwards, the method has already run: in the test, its body executed for b_user too. Use @PostAuthorize for checks that depend on what is returned by a read; never on methods that change data, since the change would already be made.

Next: That completes security. The application is now feature-complete; the last module is about running it in production. Chapter 43 covers Actuator: endpoints that show whether the application is healthy, its metrics and thread dumps, and how to secure and extend them.

Check yourself ​

  1. Why move authorization from the filter chain onto methods?
  2. How do a user's role and permissions become authorities?
  3. What is needed for @PreAuthorize to take effect?
  4. A user has ROLE_USER and ORDER_READ. Which of /api/orders and /api/sales succeed?
  5. What is the difference between hasRole('USER') and hasAuthority('USER')?
  6. Which component checks @PreAuthorize, and what kind of interceptor is it?
  7. What status does a failed check produce?
  8. How do you allow a user to read only their own record?
  9. When does @PostAuthorize run, and what can it access?
  10. Why must @PostAuthorize not be used on a method that updates data?
Answers
  1. With many APIs, one central list of URL rules is hard to maintain; annotations keep each rule next to its method.
  2. getAuthorities() returns both: the role (ROLE_USER) and each permission (ORDER_READ) as SimpleGrantedAuthority.
  3. @EnableMethodSecurity on a configuration class.
  4. /api/orders (200); /api/sales gives 403.
  5. hasRole adds the ROLE_ prefix, so it checks ROLE_USER; hasAuthority('USER') checks exactly USER.
  6. AuthorizationManagerBeforeMethodInterceptor, an AOP method interceptor around a proxy (not an MVC interceptor).
  7. AccessDeniedException, returned as 403 Forbidden.
  8. @PreAuthorize("#id == authentication.principal.id").
  9. After the method returns, before the response is sent; it can use returnObject and authentication.
  10. The method has already run, so the change is made even when the check fails.