September 13, 2026
RBAC in Angular + Spring Boot: How We Handled Role-Based Access Control
A logistics platform doesn’t have just one type of user. Hub managers, operators, admins, mobile field staff, each role needs access to…

By DevLogic - Engineering Thinking
5 min read
A logistics platform doesn't have just one type of user. Hub managers, operators, admins, mobile field staff, each role needs access to different parts of the system, and giving everyone the same access is both a security risk and a confusing experience for people who only need a fraction of what's available. On the platform I work on, we had around 10 distinct roles, each needing different combinations of what they could view, edit, or approve.
Here's how role-based access control was actually handled across both the Spring Boot backend and the Angular frontend, and why both layers mattered, not just one.
Why enforcing it in one place isn't enough
It's tempting to think of RBAC as a frontend concern, hide the buttons a user shouldn't see, and you're done. That's not enough on its own. Anyone with basic browser tools can call an API endpoint directly, bypassing whatever the UI is or isn't showing. If the backend doesn't independently verify that the calling user actually has permission for that action, hiding a button in Angular is cosmetic, not security.
The approach that actually holds up is enforcing access control at the backend, where it can't be bypassed, and using the frontend to reflect that same access control for a clean user experience, not as the actual security boundary.
Enforcing roles on the backend with @PreAuthorize
Spring Security's @PreAuthorize annotation made it possible to declare access rules directly on the controller methods that needed protecting, keeping the permission logic close to the endpoint it applies to rather than scattered across some separate configuration file.
@PreAuthorize("hasRole('HUB_MANAGER')")
@PutMapping("/tasks/{taskId}/approve")
public ResponseEntity<Task> approveTask(@PathVariable Long taskId) {
return ResponseEntity.ok(taskService.approve(taskId));
}@PreAuthorize("hasRole('HUB_MANAGER')")
@PutMapping("/tasks/{taskId}/approve")
public ResponseEntity<Task> approveTask(@PathVariable Long taskId) {
return ResponseEntity.ok(taskService.approve(taskId));
}With 10 different roles in play, some endpoints needed to allow more than one role rather than a single fixed one. @PreAuthorize supports combining roles directly in the expression:
@PreAuthorize("hasAnyRole('HUB_MANAGER', 'ADMIN')")
@GetMapping("/reports/hub-summary")
public ResponseEntity<HubSummary> getHubSummary(@RequestParam Long hubId) {
return ResponseEntity.ok(reportService.getSummary(hubId));
}@PreAuthorize("hasAnyRole('HUB_MANAGER', 'ADMIN')")
@GetMapping("/reports/hub-summary")
public ResponseEntity<HubSummary> getHubSummary(@RequestParam Long hubId) {
return ResponseEntity.ok(reportService.getSummary(hubId));
}This is what actually prevents unauthorized access, regardless of what the frontend does or doesn't show. Even if someone found the API endpoint directly and tried calling it without the right role, Spring Security rejects the request before it ever reaches the controller logic.
Getting the role information to the frontend
For the Angular side to know what to show or hide, it needs to know which role the currently logged-in user has. This came through as part of the authentication response after login, typically embedded in the JWT token issued to the user:
interface AuthResponse {
token: string;
username: string;
roles: string[];
}interface AuthResponse {
token: string;
username: string;
roles: string[];
}After login, the roles get decoded from the token and stored in an auth service that other parts of the Angular app can query:
@Injectable({ providedIn: 'root' })
export class AuthService {
private currentRoles: string[] = [];
setRoles(roles: string[]) {
this.currentRoles = roles;
}
hasRole(role: string): boolean {
return this.currentRoles.includes(role);
}
hasAnyRole(roles: string[]): boolean {
return roles.some(role => this.currentRoles.includes(role));
}
}@Injectable({ providedIn: 'root' })
export class AuthService {
private currentRoles: string[] = [];
setRoles(roles: string[]) {
this.currentRoles = roles;
}
hasRole(role: string): boolean {
return this.currentRoles.includes(role);
}
hasAnyRole(roles: string[]): boolean {
return roles.some(role => this.currentRoles.includes(role));
}
}Hiding UI elements based on role
With that service in place, showing or hiding buttons, sections, or entire menu items based on role became a matter of checking hasRole in the template:
<button
*ngIf="authService.hasRole('HUB_MANAGER')"
(click)="approveTask(task.id)">
Approve Task
</button>
<div *ngIf="authService.hasAnyRole(['ADMIN', 'HUB_MANAGER'])">
<app-hub-summary-report [hubId]="hubId"></app-hub-summary-report>
</div><button
*ngIf="authService.hasRole('HUB_MANAGER')"
(click)="approveTask(task.id)">
Approve Task
</button>
<div *ngIf="authService.hasAnyRole(['ADMIN', 'HUB_MANAGER'])">
<app-hub-summary-report [hubId]="hubId"></app-hub-summary-report>
</div>This kept the UI clean for each role. An operator logging in never sees an "Approve Task" button they can't actually use, which avoids the confusing experience of a user seeing options that then fail with a permission error the moment they try to use them.
For larger or repeated sections, wrapping this logic into a reusable structural directive kept templates from getting cluttered with repeated hasRole checks:
@Directive({ selector: '[appHasRole]' })
export class HasRoleDirective {
@Input() set appHasRole(role: string) {
if (this.authService.hasRole(role)) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private authService: AuthService
) {}
}@Directive({ selector: '[appHasRole]' })
export class HasRoleDirective {
@Input() set appHasRole(role: string) {
if (this.authService.hasRole(role)) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private authService: AuthService
) {}
}Guarding routes, not just buttons
Beyond individual UI elements, entire routes needed to be restricted, since a user shouldn't be able to navigate directly to a URL for a page they don't have access to, even if no visible link led them there. Angular's route guards handled this at the routing level:
@Injectable({ providedIn: 'root' })
export class RoleGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
canActivate(route: ActivatedRouteSnapshot): boolean {
const allowedRoles = route.data['roles'] as string[];
if (this.authService.hasAnyRole(allowedRoles)) {
return true;
}
this.router.navigate(['/unauthorized']);
return false;
}
}
{
path: 'reports',
component: ReportsComponent,
canActivate: [RoleGuard],
data: { roles: ['ADMIN', 'HUB_MANAGER'] }
}@Injectable({ providedIn: 'root' })
export class RoleGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
canActivate(route: ActivatedRouteSnapshot): boolean {
const allowedRoles = route.data['roles'] as string[];
if (this.authService.hasAnyRole(allowedRoles)) {
return true;
}
this.router.navigate(['/unauthorized']);
return false;
}
}
{
path: 'reports',
component: ReportsComponent,
canActivate: [RoleGuard],
data: { roles: ['ADMIN', 'HUB_MANAGER'] }
}This closed the gap that hiding a nav link alone would leave open, someone typing the URL directly, or navigating back through browser history to a page they'd previously had temporary access to, gets redirected rather than shown a broken or partially-loaded page.
Handling users with more than one role
With 10 roles in the system, it wasn't always a strict one-user-one-role setup. Some users legitimately needed more than one role at once, someone acting as both a hub manager and an approver for a different region, for example. This meant the role-checking logic on both sides needed to handle arrays of roles cleanly, not just a single role string, from the start.
On the backend, this is part of why hasAnyRole mattered as much as hasRole. A user with multiple roles assigned still needs access to every endpoint any one of their roles is permitted for, not just the first one checked:
@PreAuthorize("hasAnyRole('HUB_MANAGER', 'REGIONAL_APPROVER', 'ADMIN')")
@PostMapping("/approvals/regional")
public ResponseEntity<Approval> createRegionalApproval(@RequestBody ApprovalRequest request) {
return ResponseEntity.ok(approvalService.create(request));
}@PreAuthorize("hasAnyRole('HUB_MANAGER', 'REGIONAL_APPROVER', 'ADMIN')")
@PostMapping("/approvals/regional")
public ResponseEntity<Approval> createRegionalApproval(@RequestBody ApprovalRequest request) {
return ResponseEntity.ok(approvalService.create(request));
}On the frontend, the same principle applied through hasAnyRole in the auth service shown earlier. The one thing worth being careful about here is not accidentally writing UI logic that assumes a user has exactly one role, checking authService.currentRole === 'X' instead of checking membership in an array is an easy mistake that quietly breaks for anyone with multiple roles assigned, and it's the kind of bug that's easy to miss in testing if nobody on the team happens to test with a multi-role account.
Testing role-based access instead of assuming it works
Given how many roles and endpoints were involved, manually clicking through the UI as every different role to confirm access worked correctly wasn't realistic, and doing it only once during initial development left the door open for a later change to quietly break access rules for a role nobody thought to re-check.
Writing integration tests that specifically assert on access, not just on functionality, caught this early. A basic pattern looked like confirming both that an allowed role succeeds and that a disallowed role gets rejected:
@Test
void hubManagerCanApproveTask() {
mockMvc.perform(put("/tasks/1/approve")
.with(user("manager1").roles("HUB_MANAGER")))
.andExpect(status().isOk());
}
@Test
void operatorCannotApproveTask() {
mockMvc.perform(put("/tasks/1/approve")
.with(user("operator1").roles("OPERATOR")))
.andExpect(status().isForbidden());
}@Test
void hubManagerCanApproveTask() {
mockMvc.perform(put("/tasks/1/approve")
.with(user("manager1").roles("HUB_MANAGER")))
.andExpect(status().isOk());
}
@Test
void operatorCannotApproveTask() {
mockMvc.perform(put("/tasks/1/approve")
.with(user("operator1").roles("OPERATOR")))
.andExpect(status().isForbidden());
}Having both cases as explicit tests, not just the happy path, is what actually validates the access control is doing its job. A test suite that only checks the allowed role succeeds would still pass even if the @PreAuthorize annotation were accidentally removed entirely, since removing it just makes the endpoint open to everyone, which still returns success for the allowed role. The negative test, confirming the disallowed role is actually rejected, is the one that catches that specific regression.
What made managing 10 roles workable
With that many distinct roles, the thing that kept this from becoming unmanageable was consistency: every access decision, whether on an API endpoint, a UI button, or a route, referenced the same role names, defined once and used everywhere, rather than each layer inventing its own naming or logic for who's allowed to do what. When a new role got introduced or an existing role's permissions changed, that change had a clear, small set of places it needed to be reflected, the backend @PreAuthorize expressions and the frontend role checks, instead of hunting through scattered, inconsistent permission logic across the codebase.
Takeaways
Role-based access control only works if both layers agree on who's allowed to do what, and the backend is where that agreement actually gets enforced, since it's the layer that can't be bypassed by a browser tool or a direct API call. The frontend's job is reflecting that same set of rules for a clean experience, hiding buttons and guarding routes so users only ever see what they can actually use, not acting as the security boundary itself. With enough roles in play, and users who sometimes hold more than one, keeping role names and logic consistent across both layers, and testing access explicitly rather than assuming it works, is what keeps the whole system manageable and correct as it grows.