1. Introduction

Role-Based Access Control (RBAC) is a common authorization model for applications where users are granted access to features or data based on roles (for example: Admin, Manager, User). In Angular apps, RBAC should be implemented cleanly and consistently so that:

This article gives a practical, step-by-step approach to implementing RBAC in an Angular app using modern Angular patterns (services, guards, directives), JWT tokens with role claims, lazy loading, and secure server-side checks. Code samples are in TypeScript and assume Angular 14+ (the patterns are valid for Angular 17 as well). The emphasis is on a pure frontend implementation pattern combined with secure backend checks (important reminder: never rely solely on frontend checks).

2. Core concepts: roles, permissions, and claims

Before coding, clarify your model.

Two approaches:

  1. Role-based only: simpler, map roles directly to UI/route checks.

  2. Role + Permission (recommended for large apps): map roles → permissions on the server and use permission checks in the client for fine-grained control.

Prefer keeping authoritative role→permission mapping on the server; send roles or permissions as claims in JWT (small list) or fetch user permissions from a secure API at login.

3. High-level technical workflow

┌───────────────┐        ┌──────────────────────┐       ┌─────────────────┐
│  User logs in  │  --->  │ Auth Server issues   │  -->  │ Angular App     │
│  (credentials) │        │ JWT with role/perm   │       │ stores token    │
└──────┬────────┘        └────────┬─────────────┘       └───────┬─────────┘
       │                          │                             │
       │                          │                             ▼
       │                          │                ┌────────────────────────┐
       │                          │                │ AuthService parses JWT │
       │                          │                │ and exposes user roles │
       │                          │                └──────────┬─────────────┘
       │                          │                           │
       ▼                          ▼                           ▼
  User clicks route        User requests API         RouterGuard checks role
  or UI element            -> backend validates      Directive hides/shows UI
                           token & permission       Backend enforces server-side
                           (always authoritative)   authorization (never trust client)

4. Authentication vs Authorization

Angular handles the frontend part (storing token, exposing roles to components). But every protected API must validate JWT and roles server-side — the frontend is only UX & convenience.

5. Implementation overview

We’ll implement:

  1. JWT auth service that extracts roles/permissions.

  2. Route guards (CanActivate, CanLoad) for route protection and lazy modules.

  3. Structural directive (*hasRole / *hasPermission) to show/hide UI.

  4. Interceptor to attach token and optionally refresh it.

  5. Example route config and lazy module protection.

  6. Notes on server claims shape and security.

6. JWT payload and server contract

Agree on a standard JWT payload. Minimal example:

{
  "sub": "12345",
  "name": "Rajesh Gami",
  "email": "[email protected]",
  "roles": ["Admin", "Manager"],
  "permissions": ["orders.view", "orders.create"],
  "iat": 1600000000,
  "exp": 1600003600
}

Your backend should:

7. AuthService: parse token & expose observables

AuthService manages the token, provides current user roles and permission helpers, and exposes an observable so the rest of the app can react to auth changes.

// auth.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

export interface UserInfo {
  sub: string;
  name?: string;
  email?: string;
  roles: string[];
  permissions?: string[];
  exp?: number;
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private tokenKey = 'app_token';
  private userSubject = new BehaviorSubject<UserInfo | null>(null);
  public user$ = this.userSubject.asObservable();

  constructor() {
    const token = this.getToken();
    if (token) {
      const info = this.parseToken(token);
      if (info) this.userSubject.next(info);
    }
  }

  setToken(token: string) {
    localStorage.setItem(this.tokenKey, token);
    const info = this.parseToken(token);
    this.userSubject.next(info);
  }

  getToken(): string | null {
    return localStorage.getItem(this.tokenKey);
  }

  clear() {
    localStorage.removeItem(this.tokenKey);
    this.userSubject.next(null);
  }

  isAuthenticated(): boolean {
    const info = this.userSubject.value;
    return !!info && !(info.exp && info.exp * 1000 < Date.now());
  }

  hasRole(role: string): boolean {
    const info = this.userSubject.value;
    return !!info && info.roles?.includes(role);
  }

  hasAnyRole(roles: string[]): boolean {
    const info = this.userSubject.value;
    if (!info) return false;
    return roles.some(r => info.roles?.includes(r));
  }

  hasPermission(perm: string): boolean {
    const info = this.userSubject.value;
    return !!info && info.permissions?.includes(perm);
  }

  private parseToken(token: string): UserInfo | null {
    try {
      const payload = token.split('.')[1];
      const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
      return {
        sub: decoded.sub,
        name: decoded.name,
        email: decoded.email,
        roles: decoded.roles || [],
        permissions: decoded.permissions || [],
        exp: decoded.exp
      };
    } catch {
      return null;
    }
  }
}

Notes

8. HTTP interceptor to attach token & handle 401/refresh

Attach token to outgoing requests and centrally handle 401 responses (refresh flow).

// auth.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError, from } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { TokenRefreshService } from './token-refresh.service'; // optional

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private auth: AuthService, private refresh: TokenRefreshService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const token = this.auth.getToken();
    let authReq = req;
    if (token) {
      authReq = req.clone({
        setHeaders: { Authorization: `Bearer ${token}` }
      });
    }
    return next.handle(authReq).pipe(
      catchError((err: HttpErrorResponse) => {
        if (err.status === 401 && token) {
          // Attempt refresh logic
          return from(this.refresh.tryRefresh()).pipe(
            switchMap(newToken => {
              if (newToken) {
                this.auth.setToken(newToken);
                const retryReq = req.clone({
                  setHeaders: { Authorization: `Bearer ${newToken}` }
                });
                return next.handle(retryReq);
              }
              this.auth.clear();
              return throwError(() => err);
            })
          );
        }
        return throwError(() => err);
      })
    );
  }
}

Register interceptor in app.module.ts providers.

Note: token refresh flows can be complex — implement queuing to avoid parallel refresh attempts.

9. Route guards for authorization

Protect routes with CanActivate and CanLoad guards. CanLoad prevents lazy module download.

// roles.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router, CanLoad, Route } from '@angular/router';
import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class RolesGuard implements CanActivate, CanLoad {
  constructor(private auth: AuthService, private router: Router) {}

  canActivate(route: ActivatedRouteSnapshot): boolean {
    const roles = route.data['roles'] as string[] | undefined;
    if (!roles || roles.length === 0) return true;
    if (this.auth.hasAnyRole(roles)) return true;

    this.router.navigate(['/forbidden']);
    return false;
  }

  canLoad(route: Route): boolean {
    const roles = route.data && route.data['roles'] as string[] | undefined;
    if (!roles || roles.length === 0) return true;
    if (this.auth.hasAnyRole(roles)) return true;
    return false;
  }
}

Route config example

// app-routing.module.ts
const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
    canLoad: [RolesGuard],
    data: { roles: ['Admin'] }
  },
  {
    path: 'orders',
    component: OrdersComponent,
    canActivate: [RolesGuard],
    data: { roles: ['Admin', 'Manager'] }
  }
];

Remember: CanLoad blocks async module loading; CanActivate protects navigation.

10. Structural directives for UI control

Create a directive to conditionally render parts of the UI based on roles or permissions:

// has-role.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from './auth.service';
import { Subscription } from 'rxjs';

@Directive({
  selector: '[hasRole]'
})
export class HasRoleDirective {
  private roles: string[] = [];
  private sub: Subscription;

  constructor(
    private tpl: TemplateRef<any>,
    private vc: ViewContainerRef,
    private auth: AuthService
  ) {
    this.sub = this.auth.user$.subscribe(() => this.updateView());
  }

  @Input() set hasRole(value: string | string[]) {
    this.roles = Array.isArray(value) ? value : [value];
    this.updateView();
  }

  private updateView() {
    this.vc.clear();
    if (!this.roles || this.roles.length === 0) {
      return;
    }
    if (this.auth.hasAnyRole(this.roles)) {
      this.vc.createEmbeddedView(this.tpl);
    }
  }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }
}

Usage in template

<button *hasRole="'Admin'">Delete User</button>
<div *hasRole="['Manager','Admin']">Manager Dashboard</div>

Build a similar hasPermission directive if you use permission claims.

11. Dynamic menu & navigation

Generate menus based on roles to improve UX and avoid showing dead links.

Example menu service

export interface MenuItem { label: string; route?: string; roles?: string[]; children?: MenuItem[]; }

@Injectable({ providedIn: 'root' })
export class MenuService {
  constructor(private auth: AuthService) {}

  getMenu(): MenuItem[] {
    const baseMenu: MenuItem[] = [
      { label: 'Home', route: '/' },
      { label: 'Orders', route: '/orders', roles: ['Manager','Admin'] },
      { label: 'Admin', route: '/admin', roles: ['Admin'] }
    ];
    return baseMenu.filter(item => !item.roles || this.auth.hasAnyRole(item.roles));
  }
}

12. Lazy loading & module-level guards

Always use CanLoad for lazy modules to prevent module download for unauthorized users.

Also, within lazy modules, consider protecting child routes with CanActivateChild.

// in admin-routing.module.ts
const routes: Routes = [
  {
    path: '',
    component: AdminHomeComponent,
    canActivateChild: [RolesGuard],
    children: [
      { path: 'users', component: UserListComponent, data: { roles: ['Admin'] } },
    ]
  }
];

13. Token expiry, refresh & session management

Guide

14. Secure coding reminders

15. Testing RBAC behaviour

Add unit and e2e tests for:

Example Jasmine unit test for directive

it('should render element only for Admin', () => {
  authService.setToken(mockAdminToken);
  fixture.detectChanges();
  expect(fixture.nativeElement.querySelector('button')).toBeTruthy();

  authService.setToken(mockUserToken);
  fixture.detectChanges();
  expect(fixture.nativeElement.querySelector('button')).toBeNull();
});

16. Advanced topics

Attribute-based RBAC & policy engines

For complex rules (time-based access, multi-claim rules), consider using a policy engine (e.g., OPA) and fetch a decision from backend for critical flows.

Claims mapping & role changes

If roles change often, prefer fetching current permissions from an API during login rather than relying only on JWT. Combine JWT for offline, and a permissions API for dynamic checks.

Caching permissions

Cache permissions for short TTL to reduce round trips. Invalidate on logout or role update.

Audit & traceability

Record which role performed critical operations. Include role + user id in logs and, where required, use server-side audit tables.

17. Example: Putting it all together

  1. User logs in → backend returns JWT with roles claim and sets refresh cookie.

  2. Angular stores token (or reads from cookie) → AuthService parses token and broadcasts user$.

  3. Router triggers CanLoad / CanActivate for requested routes; guard checks required roles in route data.

  4. Components use *hasRole or *hasPermission directives to show/hide buttons.

  5. HTTP interceptor attaches token to API calls; backend validates role claims and allows/denies operations.

  6. On sensitive server actions, backend enforces permission checks and records an audit entry.

18. Common pitfalls & how to avoid them

19. Performance & scalability tips

20. Summary & best practice checklist