Authorisation: RBAC, Method Security and Expression Rules
URL rules versus method security, roles versus authorities, role hierarchies, and decisions that depend on the data rather than just the caller.
Two Places to Decide
Authentication answered who. Authorisation answers may they. Spring Security offers two layers, and they are complementary rather than alternatives.
URL-based rules — evaluated by AuthorizationFilter before your controller runs. Coarse, fast, and the right place for broad statements.
Method security — evaluated by a proxy around your bean. Fine-grained, can see method arguments and return values, and applies regardless of which controller (or scheduled job, or message listener) invoked the method.
Use URL rules for the shape of your API and method security for rules that depend on data. A URL rule cannot express "only the customer who placed this order", because at filter time the order hasn't been loaded.
Roles and Authorities
One model, two vocabularies, and the distinction causes real bugs.
A GrantedAuthority is just a string. hasRole("ADMIN") is sugar for hasAuthority("ROLE_ADMIN") — it prepends the prefix.
.hasRole("ADMIN") // authority must be "ROLE_ADMIN"
.hasAuthority("ROLE_ADMIN") // identical
.hasAuthority("orders:write") // no prefix — a permission
.hasAnyRole("ADMIN", "SUPPORT")
.hasAnyAuthority("orders:write", "orders:admin")The conventional split:
- Roles — coarse identities.
ROLE_USER,ROLE_ADMIN. Few, stable. - Authorities/permissions — fine-grained capabilities.
orders:read,orders:refund. Many, specific.
Mixing the two is the most common authorisation bug in Spring applications. Store an authority literally named ADMIN and write hasRole("ADMIN"), and the check looks for ROLE_ADMIN, finds nothing, and denies — or worse, the inverse arrangement grants access you didn't intend. Pick one convention, apply the prefix in exactly one place (where you build UserDetails), and write a test that asserts an admin can reach an admin endpoint and a regular user cannot.
Permissions scale better than roles
A system that starts with ROLE_ADMIN and ROLE_USER tends to acquire ROLE_SUPPORT, ROLE_SUPPORT_READONLY, ROLE_FINANCE, ROLE_FINANCE_APPROVER — one role per combination of capabilities, growing combinatorially. Each new endpoint means revisiting which of fifteen roles should reach it.
Permissions invert that. Roles become bundles of permissions, assigned in data rather than code:
return User.withUsername(account.getEmail())
.password(account.getPasswordHash())
.authorities(account.getRoles().stream()
.flatMap(role -> role.getPermissions().stream()) // roles → permissions
.map(p -> new SimpleGrantedAuthority(p.getName())) // "orders:refund"
.distinct()
.toList())
.build();Endpoints then check capabilities — hasAuthority("orders:refund") — and introducing a new role is a data change, not a deployment. The code never needs to know which roles exist.
Method Security
@Configuration
@EnableMethodSecurity // prePostEnabled is on by default
public class MethodSecurityConfig { }@Service
public class OrderService {
@PreAuthorize("hasAuthority('orders:read')")
public Order findById(Long id) { }
@PreAuthorize("hasAuthority('orders:refund') and #amount <= 1000")
public void refund(Long orderId, BigDecimal amount) { }
@PreAuthorize("#order.customerId == authentication.name")
public void update(Order order) { }
@PostAuthorize("returnObject.customerId == authentication.name")
public Order findMine(Long id) { }
}@PreAuthorize runs before the method and can see the arguments. @PostAuthorize runs after and can see returnObject.
Available in the expressions: authentication, principal, hasRole, hasAuthority, hasAnyRole, permitAll, denyAll, isAuthenticated, #parameterName, and returnObject in @PostAuthorize.
@PostAuthorize runs after the method has already executed. A failing check throws, which rolls back the transaction — but any non-transactional effect has happened: an email sent, a message published to a broker, a file written, a payment API called. Use @PostAuthorize only on read methods. For anything that mutates, the decision must be @PreAuthorize.
The self-invocation hole
Method security is the same proxy mechanism as @Transactional, @Cacheable and @Async — the AOP guide covers why. So the same limitation applies:
@Service
public class OrderService {
public void bulkRefund(List<Long> orderIds) {
for (Long id : orderIds) {
refund(id, FULL); // internal call — @PreAuthorize DOES NOT RUN
}
}
@PreAuthorize("hasAuthority('orders:refund')")
public void refund(Long orderId, RefundType type) { }
}bulkRefund has no check of its own and bypasses the one on refund. Any caller who can reach bulkRefund can refund every order. For @Transactional this class of mistake costs you a transaction; here it costs you the authorisation model.
Two fixes: annotate the entry point too, or move refund to a separate bean so the call goes through a proxy. The second is structurally safer, since it doesn't depend on remembering.
Also remember that only public methods are advised. A private or package-private @PreAuthorize method is silently unprotected — the annotation is there, reads as a control, and does nothing.
Filtering Collections
@PreFilter and @PostFilter remove elements rather than rejecting the call:
@PostFilter("filterObject.customerId == authentication.name")
public List<Order> findAll() { }
@PreFilter("filterObject.customerId == authentication.name")
public void updateAll(List<Order> orders) { }filterObject is each element in turn. Convenient, and with a serious caveat:
@PostFilter loads everything, then discards. findAll() on a million-row table fetches a million rows, builds a million objects, and filters in memory — after pagination has already been applied, so page 1 of 20 items may return 3. Filter in the query, with a where customer_id = ?, and reserve @PostFilter for small, already-bounded collections. Authorisation that works by throwing data away is both slow and, with paging, incorrect.
Role Hierarchies
Rather than hasAnyRole("ADMIN", "SUPPORT", "USER") everywhere, declare that one role implies another:
@Bean
static RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("SUPPORT")
.role("SUPPORT").implies("USER")
.build();
}
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy hierarchy) {
var handler = new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(hierarchy);
return handler;
}hasRole("USER") now passes for an admin. Note both beans must be static — they're needed during configuration, before regular bean instantiation, and a non-static definition causes a startup ordering failure that reads as an unrelated error.
Hierarchies suit genuinely nested models (admin ⊃ support ⊃ user). They're a poor fit where capabilities overlap without nesting — a finance role that can refund but not administer users isn't above or below support, and forcing it into a hierarchy produces the role explosion permissions were meant to avoid.
Decisions That Need the Database
For rules too complex for an inline expression, write a permission evaluator:
@Component("orderPermissions")
public class OrderPermissionEvaluator {
private final OrderRepository orders;
private final TeamRepository teams;
public boolean canView(Authentication auth, Long orderId) {
return orders.findById(orderId)
.map(order -> order.getCustomerEmail().equals(auth.getName())
|| teams.isAccountManagerFor(auth.getName(), order.getCustomerId()))
.orElse(false);
}
}@PreAuthorize("@orderPermissions.canView(authentication, #orderId)")
public Order findById(Long orderId) { }@beanName.method(...) calls any bean from a security expression. This keeps complex logic testable as ordinary Java rather than accumulating in an unreadable SpEL string.
Keep SpEL expressions short. An expression spanning three lines with nested conditions has no compiler checking, no IDE support, no debugger, and fails at runtime when a property is renamed. Past a simple comparison, move the logic into a bean and call it. The expression should read as a statement of the rule, not an implementation of it.
Check yourself
A service has @PreAuthorize with hasAuthority('orders:refund') on refund(), and an unannotated bulkRefund() that loops calling this.refund(). A user with orders:read reaches bulkRefund through a controller that only requires authentication. What happens?
Making 403s Useful
Authorisation denials are raised by filters or proxies, so — as established in the filter chain guide — a @ControllerAdvice doesn't format them. To match the RFC 9457 format from phase 2:
http.exceptionHandling(ex -> ex
.accessDeniedHandler((request, response, denied) -> {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/problem+json");
response.getWriter().write("""
{"type":"https://api.example.com/problems/forbidden",
"title":"Forbidden","status":403,
"detail":"You do not have permission to perform this action"}
""");
}));Note AccessDeniedException thrown by method security does reach ExceptionTranslationFilter — because the proxy throws it inside the filter chain — so this handler covers both layers.
Keep the message generic. Telling a caller which permission they lack describes your authorisation model to someone probing it.
Testing It
The half that gets skipped, and the half that catches misordered rules:
@Test
@WithMockUser(authorities = "orders:refund")
void userWithRefundPermissionCanRefund() throws Exception {
mockMvc.perform(post("/api/orders/42/refund").with(csrf()))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "orders:read")
void userWithoutRefundPermissionIsForbidden() throws Exception {
mockMvc.perform(post("/api/orders/42/refund").with(csrf()))
.andExpect(status().isForbidden());
}
@Test
void anonymousIsUnauthorised() throws Exception {
mockMvc.perform(post("/api/orders/42/refund").with(csrf()))
.andExpect(status().isUnauthorized());
}Three tests per protected operation: the permitted case, the authenticated-but-forbidden case, and the anonymous case. The middle one is the one that fails when someone reorders authorizeHttpRequests, and the only reason you'd find out before a user does.
The Mental Model, Restated
- URL rules for the shape of the API; method security for rules that depend on data.
hasRole("X")means the authorityROLE_X. Apply the prefix in one place.- Permissions scale; roles multiply. Make roles bundles of permissions, assigned in data.
@PostAuthorizeruns after the method — reads only.- Self-invocation bypasses method security, which here is privilege escalation.
@PostFilterloads everything first and breaks pagination. Filter in the query.- Role hierarchy beans must be
static. - Complex rules belong in a bean, called as
@bean.method(...). - Three tests per protected operation, including the forbidden case.
What's Next
Everything so far assumed you issue and validate your own credentials. The next guide covers delegating that to an identity provider: OAuth2 and OIDC in all three roles — resource server validating bearer tokens, client performing the authorisation code flow with PKCE, and Spring Authorization Server when you need to be the provider.