00:00

Role Based Access Control (RBAC)

Role Based Access Control (RBAC) is a way of deciding what each user can do in a system based on the role they hold. Instead of giving permissions to every user one by one, you give permissions to a role, and then give that role to users.

In simple words, RBAC answers one question: "What is this person allowed to do?" A cashier, a manager and an administrator all log in to the same banking application, but each of them sees different screens and can perform different actions.

In this tutorial you will learn how RBAC works, the rules behind it, how it compares with other access control models, and how to build it step by step in a Spring Boot 3 application using Spring Security.

1. What Is RBAC?

RBAC is an authorization model. Authorization happens after authentication:

  • Authentication checks who you are (for example, with a username and password).
  • Authorization checks what you are allowed to do once you are logged in.

In RBAC, the answer to the second question comes from the user's roles. A role groups together all the permissions needed for a job, such as "Manager" or "Support Agent". When a person joins, changes jobs or leaves, you only change which roles they have. The permissions follow automatically.

2. Why Do We Need RBAC?

Imagine an application where every logged-in user can do everything. One wrong click could delete important data, and one stolen password could expose the whole system. Managing permissions user by user is not much better: with hundreds of users, it quickly becomes messy and error-prone.

RBAC fixes both problems:

  • Better security: users get only the access their job needs.
  • Easy management: change a role once, and every user with that role is updated.
  • Fewer mistakes: new users get a ready-made, tested set of permissions.
  • Simple audits: it is easy to answer "who can approve loans?" by looking at one role.
  • Scales well: adding 1,000 new users does not mean 1,000 new permission setups.

3. Key Components of RBAC

ComponentMeaningExample
UserA person or system that uses the applicationRiya, Arjun, a reporting service
RoleA named job or responsibilityCUSTOMER, MANAGER, ADMIN
PermissionOne allowed action on one kind of resourceACCOUNT_READ, LOAN_APPROVE
User-Role assignmentWhich roles each user hasArjun → MANAGER
Role-Permission assignmentWhich permissions each role hasMANAGER → LOAN_APPROVE

The important idea is that users and permissions are never linked directly. They are always connected through roles: User → Role → Permission.

4. How RBAC Works

Every request goes through the same simple steps:

  1. Login: the user proves their identity.
  2. Load roles: the system finds the roles assigned to the user, and the permissions inside those roles.
  3. Check: for each request, the system asks, "Does this user have a role or permission that allows this action?"
  4. Decide: if yes, the request continues. If no, it is rejected with an "access denied" error (HTTP 403 Forbidden in web APIs).

5. Example: Online Banking

Consider an online banking application with three roles. The table below is called a permission matrix. It shows at a glance what each role can do.

ActionCustomerManagerAdmin
View own account and transactions✔✔✔
Transfer money from own account✔✘✘
View any customer's account✘✔✔
Approve loans✘✔✔
View branch reports✘✔✔
Create or remove users✘✘✔
Change system settings✘✘✔

Now we only assign roles to people:

  • Riya → Customer
  • Arjun → Manager
  • The IT administrator → Admin

If the bank later decides managers may also view audit logs, you add that permission to the Manager role once, and every manager gets it immediately.

6. Real-Life Analogy

Think of a hospital. Everyone works in the same building, but access depends on the job:

  • Doctors can read patient reports and prescribe medicine.
  • Nurses can record vital signs and give medicine that has been prescribed.
  • Receptionists can book appointments but cannot open medical records.

When a new nurse joins, the hospital does not write a new list of rules for that person. It simply gives them the "Nurse" badge. That badge is the role, and it opens exactly the right doors. Software RBAC works the same way.

7. Core RBAC Principles

7.1 Least Privilege

Give each role only the permissions it truly needs, and nothing more. If a role does not need to delete data, it should not be able to. This limits the damage if an account is misused or stolen.

7.2 Separation of Duties

Split sensitive tasks between different roles so that no single person can complete them alone. For example, the person who creates a large payment should not be the same person who approves it.

7.3 Role Hierarchy

Senior roles can inherit the permissions of junior roles. For example, ADMIN includes everything MANAGER can do, and MANAGER includes everything CUSTOMER can do. This avoids copying the same permissions into several roles.

8. RBAC vs ACL vs ABAC

RBAC is not the only access control model. Here is how it compares with two other popular models:

ModelAccess Is Based OnExample RuleGood For
ACL (Access Control List)A list of users stored on each resource"Riya and Arjun may edit this file"File systems, shared documents
RBAC (Role Based)The user's roles"Managers may approve loans"Most business applications
ABAC (Attribute Based)Attributes of the user, resource and environment"Managers may approve loans under ₹10 lakh during office hours in their own branch"Complex, fine-grained rules

Many real systems mix them: RBAC for the main rules, plus a few attribute checks such as "customers can only see their own account".

9. Roles and Authorities in Spring Security

Spring Security stores everything a user is allowed to do as a list of authorities (GrantedAuthority objects). Roles are simply authorities whose name starts with the prefix ROLE_.

You WriteSpring Checks For Authority
hasRole("ADMIN")ROLE_ADMIN
hasAnyRole("MANAGER", "ADMIN")ROLE_MANAGER or ROLE_ADMIN
hasAuthority("LOAN_APPROVE")LOAN_APPROVE (exact name, no prefix)

A common pattern is to use roles for broad access (who can enter an area) and permission authorities for specific actions (who can approve, delete or export).

10. Project Setup

Create a Spring Boot 3 project with the Spring Web and Spring Security starters. The test dependency adds helpers for testing secured endpoints.

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

11. URL-Based RBAC

The first layer of protection is to secure URL patterns. Each area of the API is linked to the roles that may use it. For learning, we keep three sample users in memory.

SecurityConfig.java
package com.javabytechie.bank.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Disabled only because this demo is a stateless REST API without browser sessions
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/manager/**").hasAnyRole("MANAGER", "ADMIN")
                .requestMatchers("/api/customer/**").hasAnyRole("CUSTOMER", "MANAGER", "ADMIN")
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        return new InMemoryUserDetailsManager(
            User.withUsername("riya")
                .password(encoder.encode("riya123"))
                .roles("CUSTOMER")
                .build(),
            User.withUsername("arjun")
                .password(encoder.encode("arjun123"))
                .roles("MANAGER")
                .build(),
            User.withUsername("admin")
                .password(encoder.encode("admin123"))
                .roles("ADMIN")
                .build()
        );
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

A few things to notice:

  • Rules are checked from top to bottom, and the first matching rule wins. Put specific paths before general ones.
  • .roles("MANAGER") stores the authority ROLE_MANAGER for you.
  • anyRequest().authenticated() is a safe default: any URL not listed still requires login.
  • Passwords are stored as BCrypt hashes, never as plain text.

12. Method-Level RBAC

URL rules protect whole areas. For finer control, put rules directly on methods with @PreAuthorize. This works because we added @EnableMethodSecurity to the configuration.

BankController.java
package com.javabytechie.bank.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api")
public class BankController {

    // Anyone can call this, even without logging in
    @GetMapping("/public/branches")
    public List<String> branches() {
        return List.of("Bengaluru", "Pune", "Delhi");
    }

    // Customers may see only their own account; managers and admins may see any account
    @GetMapping("/customer/accounts/{username}")
    @PreAuthorize("#username == authentication.name or hasAnyRole('MANAGER', 'ADMIN')")
    public Map<String, Object> account(@PathVariable String username) {
        return Map.of("owner", username, "balance", 25_000);
    }

    @PostMapping("/manager/loans/{loanId}/approve")
    @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
    public String approveLoan(@PathVariable Long loanId) {
        return "Loan " + loanId + " approved";
    }

    @DeleteMapping("/admin/users/{username}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> deleteUser(@PathVariable String username) {
        return ResponseEntity.noContent().build();
    }
}

The account rule mixes RBAC with an ownership check. Being a customer is not enough: #username == authentication.name also makes sure Riya can open /accounts/riya but not /accounts/arjun.

Try it with curl:

Terminal
# 200 OK: Riya opens her own account
curl -u riya:riya123 http://localhost:8080/api/customer/accounts/riya

# 403 Forbidden: Riya tries to open someone else's account
curl -u riya:riya123 http://localhost:8080/api/customer/accounts/arjun

# 200 OK: Arjun (manager) approves a loan
curl -u arjun:arjun123 -X POST http://localhost:8080/api/manager/loans/7/approve

# 401 Unauthorized: no login details sent at all
curl -X DELETE http://localhost:8080/api/admin/users/riya

Remember the difference: 401 Unauthorized means "we don't know who you are", while 403 Forbidden means "we know who you are, but your role does not allow this".

13. Role Hierarchy

Writing hasAnyRole('MANAGER', 'ADMIN') everywhere gets repetitive. A role hierarchy tells Spring that higher roles include lower ones, so you can simply write hasRole('MANAGER').

SecurityConfig.java
// Spring Security 6.3+ (Spring Boot 3.3+)
@Bean
static RoleHierarchy roleHierarchy() {
    return RoleHierarchyImpl.withDefaultRolePrefix()
            .role("ADMIN").implies("MANAGER")
            .role("MANAGER").implies("CUSTOMER")
            .build();
}

With this bean in place, an ADMIN automatically passes every hasRole('MANAGER') and hasRole('CUSTOMER') check. Your rules become shorter and easier to read:

SecurityConfig.java
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/public/**").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/manager/**").hasRole("MANAGER")
    .requestMatchers("/api/customer/**").hasRole("CUSTOMER")
    .anyRequest().authenticated()
)

14. Storing Roles in a Database

In real applications, users, roles and permissions live in a database so they can be changed without redeploying. A typical design uses three tables plus two join tables:

Table design
app_users ──< user_roles >── roles ──< role_permissions >── permissions
(id, username,              (id, name)                       (id, name)
 password, enabled)

Add spring-boot-starter-data-jpa and a database driver to your project, then map the tables to entities. Getters are left out below to keep the code short.

Permission.java
@Entity
@Table(name = "permissions")
public class Permission {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;   // e.g. LOAN_APPROVE

    // getters
}
Role.java
@Entity
@Table(name = "roles")
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;   // e.g. MANAGER (stored without the ROLE_ prefix)

    @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
}
AppUser.java
@Entity
@Table(name = "app_users")
public class AppUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;   // BCrypt hash

    private boolean enabled = true;

    @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
}
AppUserRepository.java
public interface AppUserRepository extends JpaRepository<AppUser, Long> {

    Optional<AppUser> findByUsername(String username);
}

Next, create a UserDetailsService. It loads the user and turns each role into a ROLE_ authority and each permission into a plain authority. Spring Security uses this bean automatically, so remove the in-memory one from the earlier example.

DatabaseUserDetailsService.java
package com.javabytechie.bank.security;

import com.javabytechie.bank.entity.AppUser;
import com.javabytechie.bank.entity.Permission;
import com.javabytechie.bank.entity.Role;
import com.javabytechie.bank.repository.AppUserRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.HashSet;
import java.util.Set;

@Service
public class DatabaseUserDetailsService implements UserDetailsService {

    private final AppUserRepository userRepository;

    public DatabaseUserDetailsService(AppUserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

        Set<GrantedAuthority> authorities = new HashSet<>();
        for (Role role : user.getRoles()) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
            for (Permission permission : role.getPermissions()) {
                authorities.add(new SimpleGrantedAuthority(permission.getName()));
            }
        }

        return User.withUsername(user.getUsername())
                .password(user.getPassword())
                .authorities(authorities)
                .disabled(!user.isEnabled())
                .build();
    }
}

Now you can protect actions by permission instead of by role. If you later move LOAN_APPROVE to another role, this code does not change at all:

LoanService.java
@Service
public class LoanService {

    @PreAuthorize("hasAuthority('LOAN_APPROVE')")
    public void approve(Long loanId) {
        // approve the loan
    }

    @PreAuthorize("hasAuthority('REPORT_EXPORT')")
    public byte[] exportMonthlyReport() {
        // build and return the report
        return new byte[0];
    }
}

15. Testing RBAC

Security rules are easy to break by accident, so test them. @WithMockUser runs a test as a fake logged-in user with the roles you choose.

BankControllerSecurityTest.java
package com.javabytechie.bank;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class BankControllerSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void anonymousUserIsUnauthorized() throws Exception {
        mockMvc.perform(get("/api/customer/accounts/riya"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "riya", roles = "CUSTOMER")
    void customerCanSeeOwnAccount() throws Exception {
        mockMvc.perform(get("/api/customer/accounts/riya"))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "riya", roles = "CUSTOMER")
    void customerCannotSeeOtherAccounts() throws Exception {
        mockMvc.perform(get("/api/customer/accounts/arjun"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "MANAGER")
    void managerCannotDeleteUsers() throws Exception {
        mockMvc.perform(delete("/api/admin/users/riya"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminCanDeleteUsers() throws Exception {
        mockMvc.perform(delete("/api/admin/users/riya"))
                .andExpect(status().isNoContent());
    }
}

16. RBAC in Microservices

In a microservices system, users usually log in through a central authorization server (such as Keycloak, Okta or Auth0). It issues a JWT access token that contains the user's roles. Each microservice reads the roles from the token and applies its own RBAC rules, without calling a user database.

A token payload might look like this:

JWT payload
{
  "sub": "arjun",
  "roles": ["MANAGER"],
  "iss": "https://auth.example.com",
  "exp": 1767225600
}

With spring-boot-starter-oauth2-resource-server, tell Spring Security to read the roles claim and add the ROLE_ prefix:

ResourceServerConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/manager/**").hasRole("MANAGER")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
            );

        return http.build();
    }

    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter rolesConverter = new JwtGrantedAuthoritiesConverter();
        rolesConverter.setAuthoritiesClaimName("roles");
        rolesConverter.setAuthorityPrefix("ROLE_");

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(rolesConverter);
        return converter;
    }
}

Point the service at your authorization server so it can verify token signatures:

application.properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com

An API gateway can do a first, coarse check (for example, "only staff can reach /admin routes"), but each service should still enforce its own rules. Never rely on the gateway alone.

17. Advantages and Limitations

17.1 Advantages

  • Easy to understand, explain and audit.
  • Permissions are managed in one central place.
  • New users are set up quickly by assigning a role.
  • Supports the principle of least privilege.
  • Well supported by frameworks such as Spring Security and by identity providers.

17.2 Limitations

  • Role explosion: if you create a new role for every small difference (e.g. "Manager-Pune-Weekend"), the number of roles grows out of control.
  • Not context-aware: plain RBAC cannot express rules like "only during office hours" or "only for amounts below a limit". You need extra checks or ABAC for those.
  • Ownership rules need extra code: "users can edit only their own data" is not a role. It must be checked separately, as shown in section 12.

18. Best Practices

  • Follow least privilege. Start every role with no permissions and add only what is needed.
  • Name roles after jobs (ACCOUNTANT, SUPPORT_AGENT), and permissions after actions (INVOICE_CREATE, TICKET_CLOSE).
  • Check permissions in code, not role names, for specific actions. Roles can then be reorganized without code changes.
  • Deny by default. End your URL rules with anyRequest().authenticated() or denyAll().
  • Enforce rules on the server. Hiding a button in the UI is not security.
  • Review role assignments regularly and remove access when people change jobs or leave.
  • Log access-denied events so you can spot misuse and misconfigured roles.
  • Write tests for both allowed and denied cases of every important rule.

19. Common Mistakes to Avoid

  • Adding the prefix twice. hasRole("ROLE_ADMIN") is wrong in recent Spring versions. Use hasRole("ADMIN") or hasAuthority("ROLE_ADMIN").
  • Wrong rule order. Placing /api/** before /api/admin/** means the admin rule is never reached.
  • Forgetting @EnableMethodSecurity. Without it, every @PreAuthorize annotation is silently ignored.
  • Calling a secured method from the same class. Method security works through Spring proxies, so an internal this.approve() call skips the check.
  • Giving everyone the ADMIN role "just to make it work" during development and forgetting to remove it.
  • Checking roles only in the frontend. Anyone can call your API directly with tools like curl.
  • Confusing 401 and 403. 401 means not logged in; 403 means logged in but not allowed.

20. Summary

Role Based Access Control gives permissions to roles and roles to users. This simple idea makes systems more secure, easier to manage and ready to grow.

In Spring Boot, you can apply RBAC at two levels: URL rules in a SecurityFilterChain, and method rules with @PreAuthorize. Add a role hierarchy to avoid repetition, store roles and permissions in a database for flexibility, and read roles from JWT tokens in microservices. Combine RBAC with ownership checks where needed, follow least privilege, and test both the "allowed" and "denied" paths.