Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate6 min readUpdated
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 authority ROLE_X. Mixing it with hasAuthority is 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.
  • @PostAuthorize runs 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

SecurityConfig.java
@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

MethodSecurity.java
@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

A hierarchy means a rule names the minimum role required, rather than listing every role that qualifies.
RoleHierarchyConfig.java
@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:

PermissionModel.java
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;
}
Usage.java
@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:

DocumentPermissionEvaluator.java
@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);
    }
}
Usage.java
@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:

CustomAuthorizationManager.java
@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?
hasRole("ADMIN") looks for the authority ROLE_ADMIN — it adds the prefix for you. hasAuthority("ADMIN") looks for exactly ADMIN. Mixing them is the single most common reason a rule that looks correct never matches. Pick one convention and apply it everywhere.
HTTP security or method security?
Both. HTTP rules are coarse and easy to audit — all of /admin requires ADMIN — but they only protect the HTTP entry point. Method security protects the service regardless of caller, including message listeners and scheduled jobs that no URL rule covers.
When does RBAC stop being enough?
When permissions depend on the relationship between the user and the specific resource, not just the user role. "Managers can approve expenses" is RBAC; "managers can approve expenses for their own department under 5000" is not. That is where ABAC or a PermissionEvaluator comes in.

Related tutorials