00:00

Role Based Access Control (RBAC)

Last updated:

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 (Role-Based Access Control) is a security mechanism where permissions are assigned to roles, and users are assigned to those roles.

Instead of giving permissions directly to every user, we manage access through roles.

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. 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.

7. 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".

8. 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).

9. 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>

10. URL-Based RBAC

URL-Based RBAC (Role-Based Access Control) is one of the simplest ways to control access to different parts of an application.

In this approach, access is granted or denied based on the URL pattern and the user's role. We define which roles are allowed to access specific endpoints or groups of endpoints.

For example, an application might have the following URL structure:

  • /admin/** → ADMIN only
  • /manager/** → ADMIN and MANAGER
  • /user/** → ADMIN, MANAGER, and USER

When a request arrives, Spring Security checks the requested URL and verifies whether the authenticated user has the required role.

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.

11. Method-Level RBAC

URL-level authorization protects an entire group of endpoints. However, sometimes we need finer-grained access control, where authorization depends on the specific method being called.

Spring Security provides method-level security for this purpose. We can use annotations such as @PreAuthorize directly on methods to specify which roles are allowed to execute them.

To enable method-level security, add @EnableMethodSecurity to the security configuration. Once method-level security is enabled, we can use @PreAuthorize on controller or service methods.

Let's understand it with the help of given example below.

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".

12. 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()
)

13. 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.

@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
}

The DatabaseUserDetailsService tab is the 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.

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. The LoanService tab shows an example.

14. 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());
    }
}

15. 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.

16. 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 11.

17. Best Practices

Follow these practices to keep your roles easy to manage and your application safe as it grows:

PracticeWhy it mattersExample
Follow least privilegeIf an account is misused, the damage is limited to what that role can do.Start every new role with no permissions and add only what the job needs.
Name roles after jobs, permissions after actionsClear names make roles easy to understand and review.Roles: ACCOUNTANT, SUPPORT_AGENT. Permissions: INVOICE_CREATE, TICKET_CLOSE.
Check permissions in code, not role namesRoles can be reorganized later without changing code.hasAuthority("INVOICE_APPROVE") instead of hasRole("MANAGER").
Deny by defaultAny URL you forget to list is still protected.End your URL rules with anyRequest().authenticated() or denyAll().
Enforce rules on the serverHiding a button in the UI does not stop someone calling the API directly.Put @PreAuthorize on the service method behind the button.
Review role assignments regularlyPeople change jobs, and old access quietly piles up.Run a regular access review, and remove roles when someone moves team or leaves.
Log access-denied eventsRepeated 403 errors reveal misuse or a wrongly configured role.Log the user, URL and time of every denied request.
Write tests for allowed and denied casesA broken rule is caught before it reaches production.For each important rule, one test expects 200 and one expects 403.

The third practice is the one that saves the most work later. Compare these two versions of the same method:

InvoiceService.java
// Avoid: the rule is tied to a role name
@PreAuthorize("hasRole('MANAGER')")
public void approveInvoice(Long invoiceId) { ... }

// Prefer: the rule is tied to the action
@PreAuthorize("hasAuthority('INVOICE_APPROVE')")
public void approveInvoice(Long invoiceId) { ... }

With the second version, you can later give INVOICE_APPROVE to a new role, such as FINANCE_LEAD, without touching the code.

18. Common Mistakes to Avoid

These mistakes are easy to make and often go unnoticed until someone gets access they should not have:

MistakeWhat goes wrongHow to fix it
Adding the ROLE_ prefix twicehasRole("ROLE_ADMIN") is rejected in recent Spring versions, because hasRole() adds the prefix itself.Use hasRole("ADMIN") or hasAuthority("ROLE_ADMIN").
Wrong rule orderRules are checked from top to bottom. If /api/** comes before /api/admin/**, the admin rule is never reached.Put specific rules first and general rules last.
Forgetting @EnableMethodSecurityEvery @PreAuthorize annotation is silently ignored.Add @EnableMethodSecurity to a configuration class.
Calling a secured method from the same classMethod security works through Spring proxies, so an internal this.approve() call skips the check.Move the secured method to another bean, or secure the public method that is called from outside.
Giving everyone the ADMIN roleAdded "just to make it work" during development, it is easily forgotten and reaches production.Create proper test users for each role, and review role assignments before release.
Checking roles only in the frontendAnyone can call your API directly with tools like curl.Always enforce the same rules on the server.
Confusing 401 and 403Clients cannot tell whether to log in again or to stop trying.Return 401 when the user is not logged in, and 403 when they are logged in but not allowed.

The first two mistakes are the most common. Here is what they look like in code:

Mistake 1: ROLE_ prefix
// Wrong: hasRole() adds "ROLE_" itself, so this looks for "ROLE_ROLE_ADMIN"
.requestMatchers("/admin/**").hasRole("ROLE_ADMIN")

// Right: either let hasRole() add the prefix...
.requestMatchers("/admin/**").hasRole("ADMIN")

// ...or use hasAuthority() with the full name
.requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
Mistake 2: rule order
// Wrong: "/api/**" matches admin URLs first, so any logged-in user gets in
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// Right: the specific rule comes first
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()

19. 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.