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_ADMIN can access everything (via hierarchy)
  • ROLE_MANAGER can DELETE and has inherited resource:read / resource:write if those are granted through the role → permission mapping
  • ROLE_USER can only GET if they have resource:read
  • Unknown endpoints are denied before they can be accidentally exposed

Key Takeaways

  • GrantedAuthority is just a string — roles and authorities are the same thing, differentiated by the ROLE_ prefix convention
  • hasRole("X") is shorthand for hasAuthority("ROLE_X")
  • Role hierarchies let senior roles inherit the permissions of junior roles
  • Expand roles into fine-grained permissions in UserDetailsService for flexible authorization
  • Always end your authorizeHttpRequests chain with an explicit catch-all rule

Next: Method Security: @PreAuthorize, @PostAuthorize, @Secured — move authorization out of the security config and onto the methods themselves.