Role-Based Access Control: Roles, Authorities, and Hierarchies
Roles vs. Authorities: The Distinction That Matters
Spring Security uses one interface for both roles and authorities: GrantedAuthority. Both are just strings. The difference is convention.
- Authority: a fine-grained permission string —
user:read,report:export,order:cancel - Role: a coarse-grained label that groups authorities —
ROLE_ADMIN,ROLE_MANAGER,ROLE_USER
The ROLE_ prefix is the only mechanical difference. When you call hasRole("ADMIN"), Spring Security prepends ROLE_ automatically and checks for ROLE_ADMIN. When you call hasAuthority("ROLE_ADMIN") you must include the prefix yourself.
// These two checks are equivalent:
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
// These are NOT equivalent:
.requestMatchers("/reports/**").hasRole("REPORT_VIEWER") // checks ROLE_REPORT_VIEWER
.requestMatchers("/reports/**").hasAuthority("REPORT_VIEWER") // checks REPORT_VIEWER (no prefix)
GrantedAuthority Under the Hood
GrantedAuthority is a single-method interface:
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
SimpleGrantedAuthority is the standard implementation:
new SimpleGrantedAuthority("ROLE_ADMIN")
new SimpleGrantedAuthority("user:read")
The Authentication object holds a collection of GrantedAuthority objects:
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Spring Security’s authorization decisions compare the required authority string against this collection. If any authority in the collection matches, access is granted.
Loading Roles from a Database
The most common pattern is loading roles from a UserDetails implementation backed by a database.
UserDetails with Roles
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName())) // role.getName() returns "ROLE_ADMIN" etc.
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.isEnabled(),
true, true, true,
authorities
);
}
}
Database Schema
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE roles (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) UNIQUE NOT NULL -- stored as ROLE_ADMIN, ROLE_USER, etc.
);
CREATE TABLE user_roles (
user_id BIGINT REFERENCES users(id),
role_id BIGINT REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);
Combining Roles and Fine-Grained Authorities
For larger applications, a flat role list becomes insufficient. The common pattern is to store granular permissions and group them into roles.
CREATE TABLE permissions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) UNIQUE NOT NULL -- e.g. "user:read", "order:cancel"
);
CREATE TABLE role_permissions (
role_id BIGINT REFERENCES roles(id),
permission_id BIGINT REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
When loading authorities, expand roles into their permissions:
@Override
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByUsernameWithRolesAndPermissions(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
Set<GrantedAuthority> authorities = new HashSet<>();
for (Role role : user.getRoles()) {
// Add the role itself
authorities.add(new SimpleGrantedAuthority(role.getName()));
// Expand role into its permissions
for (Permission permission : role.getPermissions()) {
authorities.add(new SimpleGrantedAuthority(permission.getName()));
}
}
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
authorities
);
}
Now your security config can mix role and permission checks:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/users/**").hasAuthority("user:read")
.requestMatchers(HttpMethod.POST, "/users/**").hasAuthority("user:write")
.requestMatchers(HttpMethod.DELETE, "/orders/**").hasAuthority("order:cancel")
.anyRequest().authenticated()
);
Role Hierarchies
Without a hierarchy, ROLE_ADMIN does not automatically include the permissions of ROLE_MANAGER or ROLE_USER. Each role is independent. Role hierarchies fix this.
Configuring a Role Hierarchy
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_MANAGER
ROLE_MANAGER > ROLE_USER
ROLE_USER > ROLE_GUEST
""");
}
@Bean
public DefaultWebSecurityExpressionHandler webSecurityExpressionHandler(
RoleHierarchy roleHierarchy) {
DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
return handler;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/manager/**").hasRole("MANAGER")
.requestMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated()
);
return http.build();
}
}
With this hierarchy, a user with ROLE_ADMIN can access /admin/**, /manager/**, and /user/**. A user with ROLE_MANAGER can access /manager/** and /user/** but not /admin/**.
Role Hierarchy with Method Security
For method-level security you need to wire the hierarchy into the method expression evaluator:
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
return handler;
}
Checking Roles Programmatically
Sometimes you need to check roles in service code, not just in security rules.
Using SecurityContextHolder
@Service
public class ReportService {
public List<Report> getReports() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isAdmin = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_ADMIN"));
if (isAdmin) {
return reportRepository.findAll();
}
String username = auth.getName();
return reportRepository.findByOwner(username);
}
}
Using AuthorizationDecisionManager
Prefer injecting AuthorizationDecisionManager or using @PreAuthorize (covered in the next article) over manual SecurityContext checks — they keep authorization logic centralized.
Custom GrantedAuthority Implementation
For complex authorization models (tenant-scoped roles, time-bounded permissions), you can implement GrantedAuthority directly:
public class TenantAwareAuthority implements GrantedAuthority {
private final String role;
private final String tenantId;
public TenantAwareAuthority(String role, String tenantId) {
this.role = role;
this.tenantId = tenantId;
}
@Override
public String getAuthority() {
return role + "@" + tenantId; // e.g. ROLE_ADMIN@tenant-42
}
public String getTenantId() {
return tenantId;
}
}
In a custom security expression you can then check both the role and the tenant:
@Component("authz")
public class AuthorizationService {
public boolean hasTenantRole(Authentication auth, String tenantId, String role) {
return auth.getAuthorities().stream()
.filter(a -> a instanceof TenantAwareAuthority)
.map(a -> (TenantAwareAuthority) a)
.anyMatch(a -> a.getTenantId().equals(tenantId)
&& a.getAuthority().startsWith(role + "@"));
}
}
Used in SpEL:
@PreAuthorize("@authz.hasTenantRole(authentication, #tenantId, 'ROLE_ADMIN')")
public void deleteTenantData(String tenantId) { ... }
Common Mistakes
Forgetting ROLE_ Prefix in Stored Data
If you store ADMIN in the database instead of ROLE_ADMIN, hasRole("ADMIN") will not match because Spring Security looks for ROLE_ADMIN.
Fix: either store with the prefix or add it during authority loading:
// Add prefix when mapping from DB
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
Mixing hasRole and hasAuthority Inconsistently
Pick one convention per application. If you use hasAuthority everywhere with explicit ROLE_ prefixes, that is fine. If you use hasRole with unprefixed strings, that is also fine. Mixing both styles for roles leads to silent failures where access is denied unexpectedly.
Granting Roles at the Wrong Layer
Roles should be loaded in UserDetailsService or the JWT converter — not scattered across controllers or services. Keep role loading centralized so the security config is the single source of truth for what each role can access.
No Deny-All Fallback
// Missing anyRequest().denyAll() — new endpoints are open by default
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
// New /internal/** endpoint added later — immediately public
);
Always end with .anyRequest().denyAll() or .anyRequest().authenticated() depending on your default posture.
Putting It Together: A Multi-Role API
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_MANAGER
ROLE_MANAGER > ROLE_USER
""");
}
@Bean
public DefaultWebSecurityExpressionHandler expressionHandler(RoleHierarchy hierarchy) {
DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
handler.setRoleHierarchy(hierarchy);
return handler;
}
@Bean
public MethodSecurityExpressionHandler methodExpressionHandler(RoleHierarchy hierarchy) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(hierarchy);
return handler;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("MANAGER")
.requestMatchers(HttpMethod.POST, "/api/**").hasAuthority("resource:write")
.requestMatchers(HttpMethod.GET, "/api/**").hasAuthority("resource:read")
.anyRequest().denyAll()
);
return http.build();
}
}
With this config:
ROLE_ADMINcan access everything (via hierarchy)ROLE_MANAGERcan DELETE and has inheritedresource:read/resource:writeif those are granted through the role → permission mappingROLE_USERcan only GET if they haveresource:read- Unknown endpoints are denied before they can be accidentally exposed
Key Takeaways
GrantedAuthorityis just a string — roles and authorities are the same thing, differentiated by theROLE_prefix conventionhasRole("X")is shorthand forhasAuthority("ROLE_X")- Role hierarchies let senior roles inherit the permissions of junior roles
- Expand roles into fine-grained permissions in
UserDetailsServicefor flexible authorization - Always end your
authorizeHttpRequestschain with an explicit catch-all rule
Next: Method Security: @PreAuthorize, @PostAuthorize, @Secured — move authorization out of the security config and onto the methods themselves.