A popular authorization paradigm for applications is called Role-Based Access Control (RBAC), in which users are given access to features or data according to their roles (Admin, Manager, User, etc.). RBAC should be applied neatly and consistently in Angular projects so that:

  • UI elements are disabled or hidden when users don't have permission.
  • To prevent unauthorized customers from navigating, routes are secured.
  • Decisions about authorization are safe and effective.
  • As roles and permissions expand, the system is still maintainable.

Using JWT tokens with role claims, lazy loading, secure server-side checks, and contemporary Angular patterns (services, guards, and directives), this article provides a useful, step-by-step method for building RBAC in an Angular application. TypeScript code samples assume Angular 14+ (but the patterns work with Angular 17 as well). The focus is on using secure backend checks in conjunction with a pure frontend implementation approach (important reminder: never rely entirely on frontend checks).

2. Core concepts: roles, permissions, and claims
Before coding, clarify your model.

  • Role: a label for a set of capabilities (e.g., Admin, Editor).
  • Permission: a fine-grained capability or action (e.g., order.create, order.view, user.manage).
  • Claims: information encoded in JWT or user profile (e.g., roles: ["Admin", "Manager"] or permissions: ["order.create"]) that the client can use to authorize UI and route behaviour.

Two approaches:

  • Role-based only: simpler, map roles directly to UI/route checks.
  • 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. Authentication vs Authorization

  • Authentication = who are you (login).
  • Authorization = what can you do (RBAC).

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.

4. Implementation overview
We’ll implement:

  • JWT auth service that extracts roles/permissions.
  • Route guards (CanActivate, CanLoad) for route protection and lazy modules.
  • Structural directive (*hasRole / *hasPermission) to show/hide UI.
  • Interceptor to attach token and optionally refresh it.
  • Example route config and lazy module protection.
  • Notes on server claims shape and security.

5. JWT payload and server contract
Agree on a standard JWT payload. Minimal example:
{
  "sub": "12345",
  "name": "Peter",
  "email": "[email protected]",
  "roles": ["Admin", "Manager"],
  "permissions": ["orders.view", "orders.create"],
  "iat": 1600000000,
  "exp": 1600003600
}

Your backend should:

  • Sign JWTs securely (RS256 recommended).
  • Keep the token payload small.
  • Revoke tokens via short expiry + refresh tokens or server revocation list for highly sensitive apps.
  • Map roles → permissions server-side so RBAC policy remains authoritative.

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

  • Use atob for Base64 decode (works in browsers). In Node or SSR consider safe decoding.
  • For security, prefer HttpOnly cookies for tokens in some contexts—localStorage is simpler but vulnerable to XSS.
  • Expose user$ for templates and components.

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

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

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

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

11. 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'] } },
    ]
  }
];


12. Token expiry, refresh & session management

  • Use short-lived access tokens and refresh tokens for security.
  • Implement refresh flow in TokenRefreshService.
  • On refresh failure, redirect to login.

Guide

  • Refresh tokens should be HttpOnly cookies where possible.
  • Keep token expiry checks in AuthService.isAuthenticated() using exp claim.

13. Secure coding reminders

  • Never trust frontend for authorization — always validate tokens & roles server-side for APIs.
  • Protect sensitive operations server-side even if you hide UI elements in the client.
  • Escape & sanitize inputs to avoid XSS which can reveal tokens in localStorage.
  • Use Content Security Policy (CSP) and secure headers.
  • Consider storing refresh tokens in HttpOnly secure cookies to reduce XSS risks.

14. Testing RBAC behaviour
Add unit and e2e tests for:

  • AuthService parsing of tokens and role logic.
  • RolesGuard responses for allowed/forbidden routes.
  • Directives rendering behavior under different role sets.
  • Integration tests: fake login with token claims + route navigation.

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

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

18. Example: Putting it all together

  • User logs in → backend returns JWT with roles claim and sets refresh cookie.
  • Angular stores token (or reads from cookie) → AuthService parses token and broadcasts user$.
  • Router triggers CanLoad / CanActivate for requested routes; guard checks required roles in route data.
  • Components use *hasRole or *hasPermission directives to show/hide buttons.
  • HTTP interceptor attaches token to API calls; backend validates role claims and allows/denies operations.
  • On sensitive server actions, backend enforces permission checks and records an audit entry.

19. Common pitfalls & how to avoid them

  • Relying only on client checks — avoid this; always secure APIs.
  • Huge JWT payloads — keep tokens small; use role ids instead of huge lists.
  • No refresh strategy — short tokens without refresh cause UX problems; implement a safe refresh flow.
  • Inconsistent role naming — define role constants centrally and share via API docs or a shared library.
  • Not handling lazy modules — failing to use CanLoad exposes lazy code to unauthorized downloads.

20. Performance & scalability tips

  • Keep auth logic light on the client; do heavy policy evaluation on the server.
  • Cache permission lookups server-side using Redis or in-memory caches.
  • For multi-tenant apps, include tenant claim and validate tenant context in guards and backend.
  • Avoid frequent calls to permission API — use local cache with TTL.

21. Summary & best practice checklist

  • Use JWT claims (roles / permissions) to drive client UI and route guards.
  • Always enforce role/permission checks server-side for APIs.
  • Protect lazy modules with CanLoad.
  • Provide structural directives for clean templates (*hasRole, *hasPermission).
  • Implement token refresh, short access token TTL, and secure refresh storage.
  • Centralize role constants and align backend/frontend contracts.
  • Test behavior via unit and e2e tests and maintain audit logs for critical operations.