DEV Community

Cover image for Why Role-Based Access Control Isn't Enough (And What to Do About It)
Dipankar Sethi
Dipankar Sethi

Posted on

Why Role-Based Access Control Isn't Enough (And What to Do About It)

Early in my career, I thought I had access control figured out. RBAC - Role-Based Access Control - seemed elegant in its simplicity. Assign users to roles (ADMIN, MANAGER, USER), secure your endpoints, and you're done.

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/v1/admin")
public ResponseEntity<?> getAdminData() {
    // Only admins can access this
}
Enter fullscreen mode Exit fullscreen mode

Beautiful. Clean. Simple.
Then production requirements hit me like a truck.

The RBAC Problem

Let's start with the basics. RBAC (Role-Based Access Control) is a security concept widely used in applications. The idea is simple: assign roles to users, then restrict API access based on those roles.
For example:

  • /api/v1/admin - Only users with ROLE_ADMIN can access
  • /api/v1/sales - Only users with ROLE_SALES can access.

You can even allow multiple roles to access the same endpoint. Seems straightforward, right?

Here's where it breaks down.
Imagine you have two users, both with the ROLE_ADMIN role. According to business requirements, one admin (let's call them the SUPER_ADMIN) should be able to revoke permissions for certain APIs, while regular admins cannot.

Or consider this scenario: You have an endpoint /api/v1/managers that displays manager data. All admins can view this data, but only specific admins can edit it.

My first instinct?
Create a new role. ROLE_SUPER_ADMIN or ROLE_ADMIN_EDITOR. Problem solved... or so I thought.
But when I stepped back and looked at the bigger picture in production, I realized this approach doesn't scale. This was just one small requirement. In a real production system, you might have:

  • Admins who can view reports but not delete them
  • Managers who can edit their team's data but not other teams
  • Sales users who can create invoices but not approve them

If we create a new role for every permission combination, we'd end up with role explosion: ROLE_ADMIN_CAN_VIEW_REPORTS, ROLE_ADMIN_CAN_EDIT_NOT_DELETE, ROLE_MANAGER_TEAM_A... you get the idea.

The real issue: Roles represent who someone is (their job title), not what they can do (specific actions). Production systems need fine-grained control over individual actions, not just broad role categories.

That's when I discovered we needed something more granular: permission-based access control.

The Solution: Granular Authorities

The key insight is this: separate what someone is (their role) from what they can do (their permissions).
Instead of creating endless roles, we introduce granular authorities - specific, atomic permissions that represent individual actions in the system. Then we assign these authorities to roles, and roles to users.
Here's the mental model:

  • User → has → Role(s) → contains → Authorities/Permissions
  • Example: John (User) → ADMIN (Role) → {READ_REPORTS, EDIT_REPORTS, DELETE_USER} (Authorities)

This three-tier structure gives us the flexibility we need. Want to give an admin editing rights but not deletion rights? Just assign different authorities to that role. No need to create ROLE_ADMIN_NO_DELETE.

Database Structure
The foundation of this approach is a proper database schema. Here's what I implemented:
Three main tables:

  1. users - Stores user information
  2. roles - Defines roles (ADMIN, MANAGER, USER, etc.)
  3. permissions - Individual authorities (READ_REPORTS, EDIT_REPORTS, CREATE_LEAD, etc.)

Two junction tables:

  1. user_roles - Maps users to their roles (many-to-many)
  2. role_permissions - Maps roles to their authorities (many-to-many)
-- Users table
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    username VARCHAR(255) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL
);

-- Roles table
CREATE TABLE roles (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL  -- ADMIN, MANAGER, USER
);

-- Permissions/Authorities table
CREATE TABLE permissions (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100) UNIQUE NOT NULL,  -- READ_REPORTS, EDIT_REPORTS, etc.
    description VARCHAR(255)
);

-- User-Role mapping (many-to-many)
CREATE TABLE user_roles (
    user_id BIGINT,
    role_id BIGINT,
    PRIMARY KEY (user_id, role_id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (role_id) REFERENCES roles(id)
);

-- Role-Permission mapping (many-to-many)
CREATE TABLE role_permissions (
    role_id BIGINT,
    permission_id BIGINT,
    PRIMARY KEY (role_id, permission_id),
    FOREIGN KEY (role_id) REFERENCES roles(id),
    FOREIGN KEY (permission_id) REFERENCES permissions(id)
);
Enter fullscreen mode Exit fullscreen mode

Why this structure works:

  • Flexibility: One role can have multiple permissions
  • Reusability: Same permission can belong to multiple roles
  • Scalability: Add new permissions without touching existing roles
  • Maintainability: Change a role's permissions without affecting users

Dynamic Permission Management
Here's the game-changer: We gave the SUPER_ADMIN the ability to edit role permissions at runtime.
This means:

  • No code deployments to change access control
  • Business users can adjust permissions through an admin panel
  • Audit trail of who changed what permissions when

The SUPER_ADMIN can:

  • Add/remove permissions from any role
  • Create new permissions as needed
  • Revoke specific authorities without changing the entire role

This was a requirement that emerged from production needs - the business team needed flexibility without waiting for engineering to deploy new role configurations.

How Authorities Flow to the User
When a user logs in, here's what happens:

  1. Fetch user's roles from user_roles table
  2. For each role, fetch its permissions from role_permissions table
  3. Combine all permissions (remove duplicates if user has multiple roles)
  4. Pass authorities in the JWT token for authorization checks
  5. Cache the authorities to avoid repeated database queries.

This way, every request carries the user's complete set of authorities, and we can make authorization decisions at the API level without hitting the database repeatedly

Implementation in Spring Boot
Let's see how this translates into actual code. I'll walk you through the entities, JWT token handling, and securing endpoints with granular authorities.

Step 1: Define the Entities
First, we need JPA entities that match our database schema:

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String email;
    private String password;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
        name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private Set<Role> roles = new HashSet<>();

    // Getters and setters
}

@Entity
@Table(name = "roles")
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name; // ADMIN, MANAGER, SALES, etc.

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
        name = "role_permissions",
        joinColumns = @JoinColumn(name = "role_id"),
        inverseJoinColumns = @JoinColumn(name = "permission_id")
    )
    private Set<Permission> permissions = new HashSet<>();

    // Getters and setters
}

@Entity
@Table(name = "permissions")
public class Permission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name; // READ_REPORTS, EDIT_REPORTS, CREATE_LEAD, etc.
    private String description;

    // Getters and setters
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • FetchType.EAGER ensures we load roles and permissions when we fetch the user
  • Many-to-many relationships are handled through @JoinTable
  • Clean separation between User → Role → Permission

Step 2: Extract User Authorities
We need a method to collect all authorities for a user across all their roles:

@Service
public class UserService {

    public Set<String> getUserAuthorities(User user) {
        Set<String> authorities = new HashSet<>();

        // Iterate through all roles
        for (Role role : user.getRoles()) {
            // Collect all permissions from each role
            for (Permission permission : role.getPermissions()) {
                authorities.add(permission.getName());
            }
        }

        return authorities;
    }
}
Enter fullscreen mode Exit fullscreen mode

This gives us a flat list of all unique authorities for a user, regardless of which role grants them.

Step 3: JWT Token with Authorities
When generating the JWT token, we include the authorities as claims:

@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${jwt.expiration}")
    private long jwtExpiration;

    public String generateToken(User user, Set<String> authorities) {
        Map<String, Object> claims = new HashMap<>();

        // Add authorities to JWT claims
        claims.put("authorities", authorities);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(user.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + jwtExpiration))
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }

    public Set<String> getAuthoritiesFromToken(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody();

        // Extract authorities from claims
        List<String> authList = (List<String>) claims.get("authorities");
        return new HashSet<>(authList);
    }

    public String getUsernameFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The client receives the JWT token in the Authorization header, and optionally, we can send a readable list of permissions in a custom X-Permissions header for client convenience:

Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...
X-Permissions: READ_REPORTS, EDIT_REPORTS, CREATE_LEAD

Step 4: JWT Authentication Filter
Create a filter to extract authorities from the JWT and set them in Spring Security context:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider tokenProvider;

    @Autowired
    private UserService userService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) 
            throws ServletException, IOException {

        try {
            String jwt = getJwtFromRequest(request);

            if (jwt != null && tokenProvider.validateToken(jwt)) {
                String username = tokenProvider.getUsernameFromToken(jwt);
                Set<String> authorities = tokenProvider.getAuthoritiesFromToken(jwt);

                // Convert to Spring Security authorities
                List<GrantedAuthority> grantedAuthorities = authorities.stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

                // Create authentication object
                UsernamePasswordAuthenticationToken authentication = 
                        new UsernamePasswordAuthenticationToken(
                                username, null, grantedAuthorities);

                // Set in security context
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
        }

        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Secure Your Endpoints
Now you can use @PreAuthorize with specific authorities:

@RestController
@RequestMapping("/api/v1")
public class ReportController {

    // Any user with READ_REPORTS authority can access
    @PreAuthorize("hasAuthority('READ_REPORTS')")
    @GetMapping("/reports")
    public ResponseEntity<List<Report>> getReports() {
        // Implementation
        return ResponseEntity.ok(reports);
    }

    // Only users with EDIT_REPORTS authority can access
    @PreAuthorize("hasAuthority('EDIT_REPORTS')")
    @PutMapping("/reports/{id}")
    public ResponseEntity<Report> editReport(@PathVariable Long id, 
                                            @RequestBody Report report) {
        // Implementation
        return ResponseEntity.ok(updatedReport);
    }
}

@RestController
@RequestMapping("/api/v1/sales")
public class SalesController {

    @PreAuthorize("hasAuthority('CREATE_LEAD')")
    @PostMapping("/leads")
    public ResponseEntity<Lead> createLead(@RequestBody Lead lead) {
        // Implementation
        return ResponseEntity.ok(createdLead);
    }

    @PreAuthorize("hasAuthority('CREATE_ORDER')")
    @PostMapping("/orders")
    public ResponseEntity<Order> createOrder(@RequestBody Order order) {
        // Implementation
        return ResponseEntity.ok(createdOrder);
    }
}
Enter fullscreen mode Exit fullscreen mode

You can also combine authorities:

// Requires BOTH authorities
@PreAuthorize("hasAuthority('READ_REPORTS') and hasAuthority('EDIT_REPORTS')")

// Requires ANY of these authorities
@PreAuthorize("hasAnyAuthority('READ_REPORTS', 'READ_ANALYTICS')")
Enter fullscreen mode Exit fullscreen mode

Step 6: SUPER_ADMIN Permission Management
The SUPER_ADMIN panel allows dynamic permission management through these endpoints:

@RestController
@RequestMapping("/api/v1/admin/permissions")
@PreAuthorize("hasAuthority('MANAGE_PERMISSIONS')") // Only SUPER_ADMIN has this
public class PermissionManagementController {

    @Autowired
    private RoleService roleService;

    // Add permission to a role
    @PostMapping("/roles/{roleId}/permissions/{permissionId}")
    public ResponseEntity<?> addPermissionToRole(
            @PathVariable Long roleId, 
            @PathVariable Long permissionId) {

        roleService.addPermissionToRole(roleId, permissionId);
        return ResponseEntity.ok("Permission added successfully");
    }

    // Remove permission from a role
    @DeleteMapping("/roles/{roleId}/permissions/{permissionId}")
    public ResponseEntity<?> removePermissionFromRole(
            @PathVariable Long roleId, 
            @PathVariable Long permissionId) {

        roleService.removePermissionFromRole(roleId, permissionId);
        return ResponseEntity.ok("Permission removed successfully");
    }

    // Get all permissions for a role
    @GetMapping("/roles/{roleId}/permissions")
    public ResponseEntity<Set<Permission>> getRolePermissions(
            @PathVariable Long roleId) {

        Set<Permission> permissions = roleService.getRolePermissions(roleId);
        return ResponseEntity.ok(permissions);
    }

    // Create new permission
    @PostMapping("/permissions")
    public ResponseEntity<Permission> createPermission(
            @RequestBody Permission permission) {

        Permission created = roleService.createPermission(permission);
        return ResponseEntity.ok(created);
    }
}
Enter fullscreen mode Exit fullscreen mode

The UI calls these endpoints to:

  • View current role-permission mappings
  • Add/remove permissions from roles
  • Create new permissions as business needs evolve

No code deployment needed - SUPER_ADMIN can adjust access control on the fly.

Security Configuration
Don't forget to configure Spring Security:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
                .antMatchers("/api/v1/auth/**").permitAll()
                .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtAuthenticationFilter, 
                           UsernamePasswordAuthenticationFilter.class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key settings:

  • @EnableGlobalMethodSecurity(prePostEnabled = true) - Enables @PreAuthorize annotations
  • Stateless session - We're using JWT, not sessions
  • JWT filter runs before Spring's authentication filter.

Best Practices & Lessons Learned
After implementing this system in production, here are some key takeaways:

1. Permission Naming Conventions
Use a consistent naming pattern for permissions. I use ACTION_RESOURCE format:

  • READ_REPORTS, EDIT_REPORTS, DELETE_REPORTS
  • CREATE_LEAD, VIEW_LEAD, CONVERT_LEAD
  • APPROVE_ORDER, CANCEL_ORDER This makes it immediately clear what action is being performed on what resource.

2. Don't Over-Engineer
Start with the permissions you actually need. It's tempting to create 50 different permissions upfront, but you'll end up with unused complexity. Add permissions as requirements emerge.

3. Performance Considerations
Loading roles and permissions on every request can be expensive.
Consider:

  • Caching user authorities after login (Redis is great for this)
  • Using FetchType.EAGER strategically (or switch to LAZY with DTOs)
  • Setting reasonable JWT expiration times (balance security vs. UX)

4. Handle Permission Changes Gracefully
When SUPER_ADMIN changes role permissions, existing JWT tokens still have the old permissions. You have two options:

  • Force users to re-login (more secure, but poor UX)
  • Implement token revocation/refresh mechanism (complex but better UX).

We went with shorter JWT expiration times (30 minutes) as a compromise.

5. Audit Everything
Log every permission change made by SUPER_ADMIN. You'll want this audit trail when debugging access issues or for compliance.

@Aspect
@Component
public class PermissionAuditAspect {

    @AfterReturning("@annotation(PreAuthorize)")
    public void logPermissionCheck(JoinPoint joinPoint) {
        // Log who accessed what with which permission
    }
}
Enter fullscreen mode Exit fullscreen mode

6. Testing Strategy
Test your security configuration thoroughly:

  • Unit tests for permission logic
  • Integration tests for actual endpoint access
  • Test edge cases (user with no roles, multiple roles with conflicting permissions)
@Test
@WithMockUser(authorities = {"READ_REPORTS"})
public void testGetReports_withReadPermission_shouldSucceed() {
    // Test passes
}

@Test
@WithMockUser(authorities = {"CREATE_LEAD"})
public void testGetReports_withoutReadPermission_shouldFail() {
    // Test should return 403
}
Enter fullscreen mode Exit fullscreen mode

When to Use This Approach
Granular permission-based access control is powerful, but it's not always necessary. Use it when:

  • You have complex, evolving access requirements
  • Different users with the same role need different permissions
  • Business users need to manage permissions without developer involvement
  • You're building a multi-tenant system
  • Compliance requires fine-grained access control

Stick with simple RBAC when:

  • Your access rules are straightforward and stable
  • Roles clearly map to job functions
  • You have a small team and simple requirements

Conclusion
Moving from role-based to permission-based access control was a game-changer for our application. We went from rigid, hard-coded roles to a flexible system that adapts to business needs without code deployments.
The three-tier structure (User → Role → Permission) strikes the perfect balance between simplicity and flexibility. Roles still represent job functions, but now they're composed of granular permissions that can be mixed and matched.
Yes, it requires more upfront setup than simple RBAC. But the payoff - in flexibility, maintainability, and business agility - is worth it.
How do you handle permissions in your applications? I'd love to hear about different approaches in the comments!

About the Author
I'm a backend developer specializing in Spring Boot and microservices architecture. Currently exploring system design patterns and sharing what I learn along the way. Connect with me on LinkedIn or follow for more backend development content!

Top comments (0)