Role-Based Access Control (RBAC)
Authorisation with roles: HTTP versus method security, role hierarchies, @PreAuthorize and @PostAuthorize, custom PermissionEvaluator, and where RBAC stops being enough.
On this page
Roles are the simplest authorisation model that works, and for most applications they are enough. The failures come from two places: confusing roles with permissions, and forgetting that a URL rule protects a URL, not a method.
Key Takeaways
hasRole("X")means the authorityROLE_X. Mixing it withhasAuthorityis the classic bug.- Apply HTTP rules for coarse paths and method security for the service layer.
- A role hierarchy removes the need to list every role in every rule.
@PostAuthorizeruns after the method — never use it on something with side effects.- Model permissions, not job titles, or every reorganisation becomes a code change.
Two layers
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // required for @PreAuthorize to do anything
public class SecurityConfig {
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Order matters: the FIRST matching rule wins, so specific
// patterns must come before general ones.
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/orders/**").hasAnyRole("USER", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/orders/**").authenticated()
.anyRequest().denyAll()); // deny by default, not permitAll
return http.build();
}
}anyRequest().denyAll() is the safer terminal rule. With permitAll(), a new endpoint added under a
path nobody thought about is public until someone notices; with denyAll(), it is inaccessible until
someone grants access. Failing closed is the correct default for authorisation.
Method security
@Service
public class ExpenseService {
@PreAuthorize("hasRole('MANAGER')")
public void approve(ExpenseId id) { }
// Combines a role check with a data check. SpEL can reference method
// parameters by name and the current authentication.
@PreAuthorize("hasRole('MANAGER') and #expense.amount <= 5000")
public void approveWithLimit(Expense expense) { }
// Runs AFTER the method. Only safe because this method has no side
// effects — the object is loaded, then the check decides whether the
// caller may see it.
@PostAuthorize("returnObject.ownerId == authentication.name or hasRole('AUDITOR')")
public Expense find(ExpenseId id) {
return repository.findById(id).orElseThrow();
}
// Filters a collection down to what the caller may see.
@PostFilter("filterObject.departmentId == authentication.principal.departmentId")
public List<Expense> findAll() {
return repository.findAll();
}
}@PostFilter is convenient and quietly expensive: it loads everything and then discards most of it.
For anything larger than a page of results, filter in the query instead — scoping the WHERE clause
to the caller is both faster and safer, because there is no window where unauthorised data existed in
memory.
The proxy limitation applies here as everywhere: a @PreAuthorize method called from another method
in the same class is not checked at all.
@PostAuthorize warrants more caution than it usually gets. It runs after the method body has already
executed, so on anything that writes, the work is done by the time the check fails. The exception then
propagates, and if the method is transactional and the interceptor ordering puts security inside the
transaction, the rollback covers for you. That is a lot of conditions to depend on. Keep
@PostAuthorize for reads, and make every write authorisation a @PreAuthorize decision.
Role hierarchy
@Bean
static RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("MANAGER")
.role("MANAGER").implies("USER")
.role("USER").implies("GUEST")
.build();
}
@Bean
static MethodSecurityExpressionHandler expressionHandler(RoleHierarchy hierarchy) {
var handler = new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(hierarchy); // needed for method security too
return handler;
}Without a hierarchy, every rule has to enumerate: hasAnyRole("USER", "MANAGER", "ADMIN"). Adding a
role means editing every rule, and the one somebody misses is a bug nobody notices until an admin
cannot do something.
Note the second bean. Registering the hierarchy for HTTP security does not apply it to method security — a genuinely surprising gap that produces rules working in one layer and not the other.
Permissions rather than job titles
The most common RBAC mistake is modelling the organisation chart. Roles named ROLE_ACCOUNTS_PAYABLE
and ROLE_REGIONAL_MANAGER_EMEA mean every reorganisation is a code change and every rule needs
domain knowledge to read.
Model capabilities instead, and group them:
public enum Permission {
ORDER_READ, ORDER_WRITE, ORDER_CANCEL,
EXPENSE_APPROVE, EXPENSE_APPROVE_UNLIMITED,
USER_MANAGE, AUDIT_READ
}
@Entity
public class Role {
private String name;
@ElementCollection(fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
private Set<Permission> permissions;
}@PreAuthorize("hasAuthority('EXPENSE_APPROVE')")
public void approve(ExpenseId id) { }Rules now say what capability is required. Which roles carry which permissions becomes data, editable by an administrator without a deploy — and a reorganisation is a configuration change.
FetchType.EAGER on that collection is deliberate. Authorities are read on every authentication, and a
lazy collection touched outside a transaction throws LazyInitializationException at precisely the
moment you can least afford it. The cost is one join per login.
This model also dissolves the hasRole/hasAuthority confusion rather than managing it. The ROLE_
prefix is a Spring convention, not a requirement, and all the trouble comes from hasRole adding it
silently while hasAuthority does not. Once every authority is a plain capability string, you can stop
using hasRole altogether: rules read literally, and the prefix stops being something anyone has to
hold in their head.
Resource-level permissions
Role checks cannot express "may edit this document". A PermissionEvaluator extends the expression
language to resource-level decisions:
@Component
public class DocumentPermissionEvaluator implements PermissionEvaluator {
private final DocumentAclRepository acl;
@Override
public boolean hasPermission(Authentication auth, Object target, Object permission) {
if (!(target instanceof Document document)) return false;
if (document.ownerId().equals(auth.getName())) return true;
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("DOCUMENT_ADMIN"))) return true;
return acl.findGrant(document.id(), auth.getName())
.map(grant -> grant.permissions().contains(permission.toString()))
.orElse(false);
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId,
String targetType, Object permission) {
// The by-id variant avoids loading the whole object just to check.
return acl.findGrant((UUID) targetId, auth.getName())
.map(grant -> grant.permissions().contains(permission.toString()))
.orElse(false);
}
}@PreAuthorize("hasPermission(#id, 'com.acme.Document', 'WRITE')")
public Document update(UUID id, DocumentPayload payload) { }Cache the ACL lookups. A permission check on every request that hits the database turns authorisation into your slowest layer, and the data changes rarely.
AuthorizationManager
Spring Security 6 replaced the old voter model with AuthorizationManager, which is simpler to
implement for genuinely custom logic:
@Component
public class TenantAuthorizationManager
implements AuthorizationManager<RequestAuthorizationContext> {
@Override
public AuthorizationDecision check(Supplier<Authentication> auth,
RequestAuthorizationContext context) {
String requestedTenant = context.getVariables().get("tenantId");
String userTenant = ((Jwt) auth.get().getPrincipal()).getClaimAsString("tenant_id");
return new AuthorizationDecision(Objects.equals(requestedTenant, userTenant));
}
}
// .requestMatchers("/api/v1/tenants/{tenantId}/**").access(tenantAuthorizationManager)Cross-tenant access is worth enforcing at this layer as well as in queries. A single missing WHERE tenant_id = ? becomes a data breach; a request-level check that the path tenant matches the token
tenant catches it regardless.
What to take away
Deny by default in HTTP rules, and add method security so non-HTTP callers are covered too. Register
the role hierarchy for both layers. Model permissions rather than job titles so reorganisations are
data changes. Use a PermissionEvaluator with caching for resource-level decisions, and enforce
tenant boundaries at the request level as well as in the query.
Frequently Asked Questions
What is the difference between hasRole and hasAuthority?
HTTP security or method security?
When does RBAC stop being enough?
Related tutorials
- LDAP & Active Directory IntegrationAuthenticating against a corporate directory: LDAP structure, bind versus password comparison, ActiveDirectoryLdapAuthenticationProvider, group-to-role mapping and LDAPS.
- Attribute-Based Access Control (ABAC)When roles are not enough: the PDP/PEP model, Open Policy Agent and Rego, integrating OPA with Spring, and deciding between ABAC and a richer RBAC.
- SAML 2.0 AuthenticationEnterprise SSO with SAML: the SP-initiated flow step by step, RelyingPartyRegistration, the assertion checks that matter, metadata exchange and single logout.
- Cryptography & Encryption in SpringApplied cryptography without inventing anything: choosing AES-GCM, envelope encryption, encrypting database columns with an AttributeConverter, and Vault Transit for key management.