Role-Based Access Control with @PreAuthorize

Roles and permissions control what authenticated users can do. This article implements a complete RBAC system — from URL-level rules to method-level security and resource ownership checks.

Roles vs Permissions

Roles are coarse-grained groupings (USER, MANAGER, ADMIN).
Permissions are fine-grained actions (READ_ORDERS, WRITE_PRODUCTS, DELETE_USERS).

Assign permissions to roles:

ADMIN     → all permissions
MANAGER   → READ_ORDERS, WRITE_ORDERS, READ_PRODUCTS, WRITE_PRODUCTS
USER      → READ_OWN_ORDERS, WRITE_OWN_ORDERS, READ_PRODUCTS

Model permissions as a typed enum:

public enum Permission {
    // Order permissions
    READ_ORDERS,
    WRITE_ORDERS,
    DELETE_ORDERS,
    READ_OWN_ORDERS,
    WRITE_OWN_ORDERS,

    // Product permissions
    READ_PRODUCTS,
    WRITE_PRODUCTS,
    DELETE_PRODUCTS,

    // User management
    READ_USERS,
    WRITE_USERS,
    DELETE_USERS
}

public enum Role {
    USER(Set.of(
        Permission.READ_PRODUCTS,
        Permission.READ_OWN_ORDERS,
        Permission.WRITE_OWN_ORDERS
    )),
    MANAGER(Set.of(
        Permission.READ_PRODUCTS,
        Permission.WRITE_PRODUCTS,
        Permission.READ_ORDERS,
        Permission.WRITE_ORDERS
    )),
    ADMIN(Set.of(Permission.values()));  // all permissions

    private final Set<Permission> permissions;

    Role(Set<Permission> permissions) { this.permissions = permissions; }

    public Set<Permission> getPermissions() { return permissions; }
}

Grant authorities from both roles and permissions:

// In User entity
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    Set<GrantedAuthority> authorities = new HashSet<>();

    for (Role role : roles) {
        // Add role itself
        authorities.add(new SimpleGrantedAuthority("ROLE_" + role.name()));

        // Add all permissions for this role
        role.getPermissions().forEach(p ->
            authorities.add(new SimpleGrantedAuthority(p.name()))
        );
    }

    return authorities;
}

URL-Level Authorization

Configure coarse-grained rules in SecurityFilterChain:

.authorizeHttpRequests(auth -> auth
    // Public
    .requestMatchers("/api/auth/**").permitAll()
    .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()

    // Permission-based
    .requestMatchers(HttpMethod.POST, "/api/products/**")
        .hasAuthority("WRITE_PRODUCTS")
    .requestMatchers(HttpMethod.DELETE, "/api/products/**")
        .hasAuthority("DELETE_PRODUCTS")

    // Role-based
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "MANAGER")

    // All other requests
    .anyRequest().authenticated()
)

Method-Level Security with @PreAuthorize

Enable it once:

@Configuration
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class MethodSecurityConfig {}

Now annotate service methods:

@Service
public class OrderService {

    // Any authenticated user can call — controller-level auth handles the rest
    @PreAuthorize("isAuthenticated()")
    public Order findById(UUID id) { ... }

    // Requires ADMIN role
    @PreAuthorize("hasRole('ADMIN')")
    public List<Order> findAll(Pageable pageable) { ... }

    // Requires specific permission
    @PreAuthorize("hasAuthority('READ_ORDERS')")
    public Page<Order> findByCustomer(UUID customerId, Pageable pageable) { ... }

    // Multiple conditions
    @PreAuthorize("hasRole('ADMIN') or hasAuthority('WRITE_ORDERS')")
    public Order confirm(UUID id) { ... }

    // Delete: admin only
    @PreAuthorize("hasRole('ADMIN')")
    public void delete(UUID id) { ... }
}

Resource Ownership Checks

The most common real-world check: a user can only access their own resources.

@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(authentication, #id)")
public Order getOrder(UUID id) { ... }

The @orderSecurity bean evaluates ownership:

@Component("orderSecurity")
@RequiredArgsConstructor
public class OrderSecurityService {

    private final OrderRepository orderRepository;

    public boolean isOwner(Authentication auth, UUID orderId) {
        if (auth == null || !auth.isAuthenticated()) return false;

        String username = auth.getName();
        return orderRepository.findById(orderId)
            .map(order -> {
                // Assuming Order has a reference to the customer username
                return order.getCustomerUsername().equals(username);
            })
            .orElse(false);
    }

    public boolean canAccessCustomerData(Authentication auth, UUID customerId) {
        if (hasRole(auth, "ADMIN")) return true;

        UserDetails user = (UserDetails) auth.getPrincipal();
        // User can access their own customer record
        return ((User) user).getId().equals(customerId);
    }

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

@PostAuthorize — Check After the Method Runs

// Return the order only if the caller owns it (or is admin)
@PostAuthorize("hasRole('ADMIN') or returnObject.customerUsername == authentication.name")
public Order getOrder(UUID id) {
    return orderRepository.findById(id).orElseThrow();
}

@PostAuthorize is useful when you need to check properties of the returned object. The method runs first, the check happens after. Use sparingly — it wastes the method call if the check fails.

@PreFilter and @PostFilter

Filter a collection before/after the method:

// Only pass items the user has permission to modify
@PreFilter("hasRole('ADMIN') or filterObject.customerId == authentication.principal.id")
public List<Order> bulkConfirm(List<Order> orders) { ... }

// Remove items the user shouldn't see from the result
@PostFilter("hasRole('ADMIN') or filterObject.customerId == authentication.principal.id")
public List<Order> findAllForUser(UUID customerId) {
    return orderRepository.findByCustomerId(customerId);
}

Use @PostFilter for small result sets — it loads everything from the DB then filters in memory. For large datasets, filter in the query instead.

Custom Security Expressions

Create domain-specific security expressions by extending SecurityExpressionRoot:

public class CustomSecurityExpressionRoot extends SecurityExpressionRoot
        implements MethodSecurityExpressionOperations {

    private final OrderRepository orderRepository;
    private Object filterObject;
    private Object returnObject;
    private Object target;

    public CustomSecurityExpressionRoot(Authentication auth,
                                         OrderRepository orderRepository) {
        super(auth);
        this.orderRepository = orderRepository;
    }

    // Custom expression: @security.ownsOrder(#orderId)
    public boolean ownsOrder(UUID orderId) {
        return orderRepository.findById(orderId)
            .map(o -> o.getCustomerUsername().equals(getAuthentication().getName()))
            .orElse(false);
    }

    // Custom expression: @security.hasTeamAccess(#teamId)
    public boolean hasTeamAccess(UUID teamId) {
        UserDetails user = (UserDetails) getAuthentication().getPrincipal();
        return ((User) user).getTeamId() != null
            && ((User) user).getTeamId().equals(teamId);
    }

    @Override
    public void setFilterObject(Object filterObject) { this.filterObject = filterObject; }
    @Override
    public Object getFilterObject() { return filterObject; }
    @Override
    public void setReturnObject(Object returnObject) { this.returnObject = returnObject; }
    @Override
    public Object getReturnObject() { return returnObject; }
    @Override
    public Object getThis() { return target; }
}

Register the custom root:

@Component
public class CustomMethodSecurityExpressionHandler
        extends DefaultMethodSecurityExpressionHandler {

    private final OrderRepository orderRepository;

    @Override
    protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
            Authentication authentication, MethodInvocation invocation) {
        var root = new CustomSecurityExpressionRoot(authentication, orderRepository);
        root.setPermissionEvaluator(getPermissionEvaluator());
        root.setTrustResolver(getTrustResolver());
        root.setRoleHierarchy(getRoleHierarchy());
        return root;
    }
}

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {

    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
            CustomMethodSecurityExpressionHandler handler) {
        return handler;
    }
}

Usage:

@PreAuthorize("ownsOrder(#orderId) or hasRole('ADMIN')")
public Order getOrder(UUID orderId) { ... }

Role Hierarchy

Define inheritance so ADMIN implicitly has all MANAGER permissions:

@Bean
public RoleHierarchy roleHierarchy() {
    return RoleHierarchyImpl.fromHierarchy("""
        ROLE_ADMIN > ROLE_MANAGER
        ROLE_MANAGER > ROLE_USER
        """);
}

@Bean
public DefaultWebSecurityExpressionHandler expressionHandler() {
    DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
    handler.setRoleHierarchy(roleHierarchy());
    return handler;
}

Now hasRole('USER') is satisfied by ADMIN, MANAGER, and USER — no need to list all three.

Testing Security

Test authorization in isolation with @WithMockUser:

@WebMvcTest(OrderController.class)
class OrderSecurityTest {

    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService;

    @Test
    @WithMockUser(roles = "USER")
    void userCannotDeleteOrders() throws Exception {
        mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID()))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminCanDeleteOrders() throws Exception {
        doNothing().when(orderService).delete(any());

        mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID()))
            .andExpect(status().isNoContent());
    }

    @Test
    void unauthenticatedUserCannotAccessOrders() throws Exception {
        mockMvc.perform(get("/api/orders"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "alice", roles = "USER")
    void userCanAccessTheirOwnOrders() throws Exception {
        UUID orderId = UUID.randomUUID();
        Order order = new Order();
        order.setCustomerUsername("alice");

        when(orderService.getOrder(orderId)).thenReturn(OrderResponse.from(order));

        mockMvc.perform(get("/api/orders/{id}", orderId))
            .andExpect(status().isOk());
    }
}

Test with a custom security context:

@Test
void shouldDenyAccessToOtherUsersOrders() throws Exception {
    UUID orderId = UUID.randomUUID();

    // Order belongs to 'bob', but 'alice' is making the request
    when(orderService.getOrder(orderId))
        .thenThrow(new AccessDeniedException("Access denied"));

    mockMvc.perform(get("/api/orders/{id}", orderId)
            .with(user("alice").roles("USER")))
        .andExpect(status().isForbidden());
}

What You’ve Learned

  • Separate roles (coarse) from permissions (fine-grained) — roles bundle permissions
  • Grant both ROLE_X and permission authorities from getAuthorities() — enables flexible checks
  • @PreAuthorize with SpEL expressions for method-level security — hasRole(), hasAuthority(), #param
  • @Component beans referenced with @ in SpEL (@orderSecurity.isOwner(...)) for resource ownership
  • Role hierarchy (ADMIN > MANAGER > USER) prevents permission explosion
  • Test security with @WithMockUser and .with(user(...))

Next: Article 27 — OAuth2 Resource Server — validate JWTs from an external auth provider (Keycloak, Auth0, Okta).