Modern Angular Guards: Architecture, Best Practices & Enterprise Patterns
A deep dive into designing lightweight, composable, and maintainable routing guards in modern Angular applications.
Table of Contents
- Introduction
- Why Guards Exist
- The Golden Rule of Angular Guards
- Functional Guards: The Modern Standard
- CanActivateFn: Authentication Guard
- CanMatchFn: Permission-Based Route Matching
- CanDeactivateFn: Unsaved Changes Guard
- CanActivateChildFn: Nested Route Protection
- Signals + Guards: Reactive Permission State
- Feature Flags in Routing
- Guard Composition Patterns
- UrlTree Redirects vs Imperative Navigation
- Async Guards: When and How
- Permission Service Architecture
- Role-Based Access Control (RBAC)
- Permission-Based Access Control (PBAC)
- Route Data for Configuration
- Lazy Loading with Guards
- Standalone Routing with provideRouter
- Route-Level Providers
- Guards vs Interceptors
- Guards vs Backend Authorization
- Performance Considerations
- Navigation UX Best Practices
- Error Handling in Guards
- Testing Guards
- Common Mistakes
- Production Checklist
- Enterprise Routing Insights
- Conclusion
Introduction
In modern Angular applications, routing guards have evolved from class-based monoliths into lightweight, composable functions. This shift isn't just syntacticโit's architectural.
As Angular applications become larger and more complex, the routing layer becomes a critical piece of the architecture. Guards are the gatekeepers of your navigation, but they should never become the orchestrators of your application logic.
This article is for senior Angular developers, software architects, and team leads who are designing routing strategies for enterprise-scale applications. We won't explain what a route guard isโwe'll explore how to architect them properly.
Why Guards Exist
Guards exist to protect navigation boundaries. They evaluate whether a transition should proceed, redirect, or be blocked. In modern Angular, this is achieved through functional guards that return:
-
booleanโ allow or block navigation -
UrlTreeโ redirect to a different route -
Observable<boolean | UrlTree>โ async decision -
Promise<boolean | UrlTree>โ async decision
The router evaluates guards during the navigation pipeline, before component activation. This makes them ideal for:
- Authentication checks
- Authorization checks
- Feature flag evaluation
- Route preloading decisions
- Unsaved changes confirmation
The Golden Rule of Angular Guards
"A guard should decide navigationโnot orchestrate your application."
When guards grow beyond ~20 lines, they typically start doing too much:
- API calls to permission endpoints
- State mutations
- Complex permission evaluations
- Side effects
- Token refresh logic
This creates tight coupling between routing and business logic, making your navigation layer brittle and hard to test.
The Modern Pattern:
Guard โ Authorization Service โ Permission Engine โ Decision
Guards read. Services decide. The routing layer stays clean.
Functional Guards: The Modern Standard
Angular v14+ introduced functional guards as the recommended approach. They replace class-based guards with simple functions that use inject() for dependency injection.
Why Functional Guards?
- No class boilerplate โ Just a function
- Composable โ Combine small functions into larger guards
- Testable โ Easy to unit test in isolation
- Type-safe โ Full TypeScript inference
-
Modern DI โ
inject()works without constructors
Legacy vs Modern
// โ Legacy Class Guard (still works, but not recommended for new code)
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(
private auth: AuthService,
private router: Router
) {}
canActivate(): boolean | UrlTree {
return this.auth.isAuthenticated()
? true
: this.router.parseUrl('/login');
}
}
// โ
Modern Functional Guard
export const authGuard: CanActivateFn = (): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated()
? true
: router.parseUrl('/login');
};
CanActivateFn: Authentication Guard
The most common guard. Evaluates whether a user can access a route.
// guards/auth.guard.ts
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
/**
* Authentication guard that checks if the user is logged in.
* Redirects to login page if not authenticated.
*/
export const authGuard: CanActivateFn = (): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated()
? true
: router.parseUrl('/login');
};
Route Configuration
// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
export const routes: Routes = [
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./dashboard/dashboard.component')
.then(m => m.DashboardComponent)
}
];
CanMatchFn: Permission-Based Route Matching
CanMatch is the modern replacement for CanLoad. It evaluates before route resolution and can prevent route registration entirely.
Why CanMatch > CanLoad?
- More flexible โ Works with all route types, not just lazy-loaded
- Better tree shaking โ Prevents unnecessary chunk downloads
- Composable โ Can combine multiple matching conditions
- Modern API โ Functional, with full DI support
// guards/permission.guard.ts
import { CanMatchFn } from '@angular/router';
import { inject } from '@angular/core';
import { PermissionService } from '../services/permission.service';
/**
* Creates a CanMatch guard that checks for a specific role.
* Prevents route registration if the user doesn't have the role.
*/
export const canMatchRole = (role: string): CanMatchFn => {
return () => {
const permissions = inject(PermissionService);
return permissions.hasRole(role);
};
};
// Usage in routes
export const routes: Routes = [
{
path: 'admin',
canMatch: [canMatchRole('admin')],
loadChildren: () => import('./admin/admin.routes')
.then(m => m.ADMIN_ROUTES)
}
];
Combined Matching
// guards/combined-match.guard.ts
import { CanMatchFn } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
import { PermissionService } from '../services/permission.service';
import { FeatureFlagService } from '../services/feature-flag.service';
/**
* Combined guard that checks auth, permissions, and feature flags.
* All conditions must pass for the route to match.
*/
export const combinedMatchGuard: CanMatchFn = () => {
const auth = inject(AuthService);
const permissions = inject(PermissionService);
const features = inject(FeatureFlagService);
return auth.isAuthenticated() &&
permissions.hasRole('admin') &&
features.isEnabled('admin-portal');
};
CanDeactivateFn: Unsaved Changes Guard
Prevents navigation away from a component when there are unsaved changes.
// guards/unsaved-changes.guard.ts
import { CanDeactivateFn } from '@angular/router';
import { Observable } from 'rxjs';
/**
* Interface for components that can prevent navigation.
*/
export interface CanComponentDeactivate {
canDeactivate: () => boolean | Observable<boolean> | Promise<boolean>;
}
/**
* Guard that checks if a component has unsaved changes before allowing navigation.
*/
export const unsavedChangesGuard: CanDeactivateFn<CanComponentDeactivate> =
(component) => {
// If component implements the interface, delegate to it
if (component.canDeactivate) {
return component.canDeactivate();
}
// Otherwise, allow navigation
return true;
};
Component Implementation
// profile-edit.component.ts
import { Component, signal } from '@angular/core';
import { CanComponentDeactivate } from '../guards/unsaved-changes.guard';
@Component({
selector: 'app-profile-edit',
template: `...`,
standalone: true
})
export class ProfileEditComponent implements CanComponentDeactivate {
private originalData = signal<ProfileData>({ name: '', email: '' });
private currentData = signal<ProfileData>({ name: '', email: '' });
readonly hasUnsavedChanges = computed(() =>
JSON.stringify(this.originalData()) !== JSON.stringify(this.currentData())
);
canDeactivate(): boolean {
if (!this.hasUnsavedChanges()) {
return true;
}
return confirm('You have unsaved changes. Are you sure you want to leave?');
}
}
Route Configuration
export const routes: Routes = [
{
path: 'profile/edit',
canDeactivate: [unsavedChangesGuard],
loadComponent: () => import('./profile-edit.component')
.then(m => m.ProfileEditComponent)
}
];
CanActivateChildFn: Nested Route Protection
Protects all child routes of a parent route. Useful for admin sections or authenticated areas.
// guards/auth-child.guard.ts
import { CanActivateChildFn, Router, UrlTree } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
/**
* Guard that protects all child routes.
* Checks authentication before allowing access to any child route.
*/
export const authChildGuard: CanActivateChildFn = (): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated()
? true
: router.parseUrl('/login');
};
Route Configuration
export const routes: Routes = [
{
path: 'admin',
canActivateChild: [authChildGuard],
loadComponent: () => import('./admin-shell.component')
.then(m => m.AdminShellComponent),
children: [
{
path: 'users',
loadComponent: () => import('./users/users.component')
},
{
path: 'settings',
loadComponent: () => import('./settings/settings.component')
}
]
}
];
Signals + Guards: Reactive Permission State
Modern Angular with Signals allows guards to read reactive state without subscriptions, eliminating memory leaks and simplifying code.
Permission Service with Signals
// services/permission.service.ts
import { Injectable, signal, computed } from '@angular/core';
export interface User {
id: string;
name: string;
roles: string[];
permissions: string[];
}
@Injectable({ providedIn: 'root' })
export class PermissionService {
// Private writable signal
private user = signal<User | null>(null);
// Public readonly computed signals
readonly roles = computed(() => this.user()?.roles ?? []);
readonly permissions = computed(() => this.user()?.permissions ?? []);
readonly isAdmin = computed(() =>
this.roles().includes('admin')
);
readonly isModerator = computed(() =>
this.roles().includes('moderator')
);
// Dynamic permission check using computed
readonly canAccess = (resource: string) => computed(() =>
this.permissions().includes(resource)
);
// Synchronous methods for guard usage
hasRole(role: string): boolean {
return this.roles().includes(role);
}
hasPermission(permission: string): boolean {
return this.permissions().includes(permission);
}
// Update user state
setUser(user: User | null): void {
this.user.set(user);
}
clearUser(): void {
this.user.set(null);
}
}
Guard Reading Signals
// guards/admin.guard.ts
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { inject } from '@angular/core';
import { PermissionService } from '../services/permission.service';
/**
* Guard that checks if the user has admin role.
* Reads directly from the reactive signal state.
*/
export const adminGuard: CanActivateFn = (): boolean | UrlTree => {
const permissions = inject(PermissionService);
const router = inject(Router);
// Direct signal read โ no subscriptions, no memory leaks
return permissions.isAdmin()
? true
: router.parseUrl('/unauthorized');
};
Dynamic Permission Guard
// guards/permission.guard.ts
import { CanActivateFn, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
import { inject } from '@angular/core';
import { PermissionService } from '../services/permission.service';
/**
* Creates a guard that checks for a specific permission.
* The permission can be passed via route data.
*/
export const permissionGuard = (requiredPermission?: string): CanActivateFn => {
return (route: ActivatedRouteSnapshot): boolean | UrlTree => {
const permissions = inject(PermissionService);
const router = inject(Router);
// Use route data if no permission provided
const permission = requiredPermission ?? route.data['requiredPermission'];
if (!permission) {
console.warn('No permission specified for route:', route.url);
return router.parseUrl('/unauthorized');
}
return permissions.hasPermission(permission)
? true
: router.parseUrl('/unauthorized');
};
};
Feature Flags in Routing
Feature flags allow you to enable/disable routes without code changes. Combine them with guards for clean routing.
Feature Flag Service
// services/feature-flag.service.ts
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
private flags = signal<Record<string, boolean>>({});
readonly enabledFlags = computed(() =>
Object.entries(this.flags())
.filter(([, enabled]) => enabled)
.map(([name]) => name)
);
isEnabled(flag: string): boolean {
return this.flags()[flag] ?? false;
}
setFlag(flag: string, enabled: boolean): void {
this.flags.update(current => ({
...current,
[flag]: enabled
}));
}
setFlags(flags: Record<string, boolean>): void {
this.flags.set(flags);
}
}
Feature Flag Guard
// guards/feature-flag.guard.ts
import { CanMatchFn, Router, UrlTree } from '@angular/router';
import { inject } from '@angular/core';
import { FeatureFlagService } from '../services/feature-flag.service';
/**
* Creates a CanMatch guard that checks if a feature flag is enabled.
* Prevents route registration if the feature is disabled.
*/
export const featureFlagGuard = (flag: string): CanMatchFn => {
return () => {
const features = inject(FeatureFlagService);
const router = inject(Router);
return features.isEnabled(flag)
? true
: router.parseUrl('/not-found');
};
};
Route Configuration with Feature Flags
export const routes: Routes = [
{
path: 'beta-dashboard',
canMatch: [featureFlagGuard('beta-dashboard')],
loadComponent: () => import('./beta-dashboard.component')
},
{
path: 'new-analytics',
canMatch: [featureFlagGuard('new-analytics')],
loadChildren: () => import('./analytics/analytics.routes')
}
];
Guard Composition Patterns
Complex authorization often requires combining multiple guards. Instead of creating monolithic guards, compose smaller ones.
Sequential Composition
// guards/composition.guard.ts
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { inject } from '@angular/core';
/**
* Composes multiple guards into a single guard.
* All guards must return true for navigation to proceed.
*/
export const composeGuards = (...guards: CanActivateFn[]): CanActivateFn => {
return (route, state) => {
const router = inject(Router);
for (const guard of guards) {
const result = guard(route, state);
if (result !== true) {
return result; // Return the first failure (UrlTree or false)
}
}
return true;
};
};
// Usage
export const adminRouteGuard = composeGuards(
authGuard,
adminGuard,
(route) => {
// Inline guard for specific route logic
const router = inject(Router);
const features = inject(FeatureFlagService);
return features.isEnabled('admin-portal')
? true
: router.parseUrl('/coming-soon');
}
);
Role-Based Composition
// guards/role-composition.guard.ts
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { inject } from '@angular/core';
import { PermissionService } from '../services/permission.service';
/**
* Creates a guard that requires ANY of the specified roles.
*/
export const requireAnyRole = (...roles: string[]): CanActivateFn => {
return () => {
const permissions = inject(PermissionService);
const router = inject(Router);
const hasAnyRole = roles.some(role => permissions.hasRole(role));
return hasAnyRole
? true
: router.parseUrl('/unauthorized');
};
};
/**
* Creates a guard that requires ALL of the specified roles.
*/
export const requireAllRoles = (...roles: string[]): CanActivateFn => {
return () => {
const permissions = inject(PermissionService);
const router = inject(Router);
const hasAllRoles = roles.every(role => permissions.hasRole(role));
return hasAllRoles
? true
: router.parseUrl('/unauthorized');
};
};
// Usage
export const routes: Routes = [
{
path: 'moderator',
canActivate: [requireAnyRole('admin', 'moderator')],
loadComponent: () => import('./moderator.component')
},
{
path: 'super-admin',
canActivate: [requireAllRoles('admin', 'superuser')],
loadComponent: () => import('./super-admin.component')
}
];
UrlTree Redirects vs Imperative Navigation
Always prefer returning UrlTree over calling router.navigate() in guards.
Why UrlTree?
- Declarative โ Part of the navigation pipeline
- Composable โ Can be chained and transformed
- Testable โ Easy to assert in unit tests
- Predictable โ Router handles the redirect properly
// โ Bad: Imperative navigation
export const badRedirectGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isAuthenticated()) {
router.navigate(['/login']); // โ Side effect in guard
return false;
}
return true;
};
// โ
Good: Declarative UrlTree
export const goodRedirectGuard: CanActivateFn = (): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated()
? true
: router.parseUrl('/login'); // โ
Clean, declarative
};
Redirect with Query Parameters
export const loginRedirectGuard: CanActivateFn = (route): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
// Redirect to login with return URL
const returnUrl = route.url.map(s => s.path).join('/');
return router.parseUrl(`/login?returnUrl=${encodeURIComponent(returnUrl)}`);
};
Async Guards: When and How
Async guards are necessary when you need to fetch data before making a navigation decision. However, they block navigation until resolved.
When to Use Async Guards
- Fetching user permissions on app initialization
- Validating tokens with the server
- Checking feature flags from remote config
When to Avoid Async Guards
- Frequent API calls โ Cache permissions instead
- Heavy RxJS pipelines โ Keep it simple
- Long-running operations โ Use resolvers instead
Async Guard Example
// guards/async-permission.guard.ts
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, catchError, take } from 'rxjs/operators';
import { PermissionService } from '../services/permission.service';
/**
* Async guard that fetches permissions before allowing navigation.
* Use sparingly โ prefer cached permissions for better performance.
*/
export const asyncPermissionGuard = (requiredPermission: string): CanActivateFn => {
return (): Observable<boolean | UrlTree> => {
const permissions = inject(PermissionService);
const router = inject(Router);
return permissions.fetchPermissions().pipe(
take(1),
map(() => {
return permissions.hasPermission(requiredPermission)
? true
: router.parseUrl('/unauthorized');
}),
catchError(() => of(router.parseUrl('/error')))
);
};
};
Optimized Async Guard with Caching
// services/permission.service.ts (extended)
@Injectable({ providedIn: 'root' })
export class PermissionService {
private user = signal<User | null>(null);
private permissionsLoaded = signal(false);
readonly isReady = computed(() => this.permissionsLoaded());
fetchPermissions(): Observable<void> {
// Return cached result if already loaded
if (this.permissionsLoaded()) {
return of(undefined);
}
const http = inject(HttpClient);
return http.get<User>('/api/me').pipe(
tap(user => {
this.user.set(user);
this.permissionsLoaded.set(true);
}),
map(() => undefined)
);
}
}
Permission Service Architecture
A well-designed permission service is the foundation of clean guards. It should:
- Centralize all permission logic
- Provide both synchronous and reactive APIs
- Cache permissions to avoid repeated API calls
- Handle permission updates gracefully
// services/permission.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap, shareReplay } from 'rxjs/operators';
export interface PermissionSet {
roles: string[];
permissions: string[];
features: string[];
}
@Injectable({ providedIn: 'root' })
export class PermissionService {
private http = inject(HttpClient);
// Reactive state
private permissionSet = signal<PermissionSet | null>(null);
private loading = signal(false);
// Computed values
readonly roles = computed(() => this.permissionSet()?.roles ?? []);
readonly permissions = computed(() => this.permissionSet()?.permissions ?? []);
readonly features = computed(() => this.permissionSet()?.features ?? []);
// Permission checks
readonly isAdmin = computed(() => this.hasRole('admin'));
readonly isModerator = computed(() => this.hasRole('moderator'));
// Cache for fetchPermissions
private permissions$?: Observable<PermissionSet>;
/**
* Fetches permissions from the server.
* Results are cached to avoid repeated API calls.
*/
fetchPermissions(): Observable<PermissionSet> {
if (this.permissions$) {
return this.permissions$;
}
this.loading.set(true);
this.permissions$ = this.http.get<PermissionSet>('/api/permissions').pipe(
tap(set => {
this.permissionSet.set(set);
this.loading.set(false);
}),
shareReplay(1)
);
return this.permissions$;
}
/**
* Synchronous role check.
* Use in guards for fast decisions.
*/
hasRole(role: string): boolean {
return this.roles().includes(role);
}
/**
* Synchronous permission check.
*/
hasPermission(permission: string): boolean {
return this.permissions().includes(permission);
}
/**
* Check if a feature is enabled.
*/
hasFeature(feature: string): boolean {
return this.features().includes(feature);
}
/**
* Check if user has any of the specified roles.
*/
hasAnyRole(roles: string[]): boolean {
return roles.some(role => this.hasRole(role));
}
/**
* Check if user has all specified permissions.
*/
hasAllPermissions(permissions: string[]): boolean {
return permissions.every(p => this.hasPermission(p));
}
/**
* Clear cached permissions (e.g., on logout).
*/
clear(): void {
this.permissionSet.set(null);
this.permissions$ = undefined;
}
}
Role-Based Access Control (RBAC)
RBAC assigns permissions based on roles. It's simple and effective for most applications.
RBAC Guard
// guards/rbac.guard.ts
import { CanActivateFn, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
import { inject } from '@angular/core';
import { PermissionService } from '../services/permission.service';
/**
* Route data interface for RBAC configuration.
*/
export interface RBACData {
requiredRoles: string[];
requireAll?: boolean; // Default: false (any role is sufficient)
}
/**
* RBAC guard that checks user roles from route data.
*/
export const rbacGuard: CanActivateFn = (
route: ActivatedRouteSnapshot
): boolean | UrlTree => {
const permissions = inject(PermissionService);
const router = inject(Router);
const data = route.data as RBACData;
const requiredRoles = data.requiredRoles ?? [];
if (requiredRoles.length === 0) {
console.warn('No roles specified for route:', route.url);
return true; // Allow if no roles specified
}
const hasAccess = data.requireAll
? requiredRoles.every(role => permissions.hasRole(role))
: requiredRoles.some(role => permissions.hasRole(role));
return hasAccess
? true
: router.parseUrl('/unauthorized');
};
Route Configuration with RBAC
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard, rbacGuard],
data: { requiredRoles: ['admin'] } as RBACData,
loadComponent: () => import('./admin/admin.component')
},
{
path: 'moderator',
canActivate: [authGuard, rbacGuard],
data: { requiredRoles: ['admin', 'moderator'] } as RBACData,
loadComponent: () => import('./moderator/moderator.component')
},
{
path: 'super-admin',
canActivate: [authGuard, rbacGuard],
data: { requiredRoles: ['admin', 'superuser'], requireAll: true } as RBACData,
loadComponent: () => import('./super-admin/super-admin.component')
}
];
Permission-Based Access Control (PBAC)
PBAC is more granular than RBAC. It checks specific permissions rather than roles, offering finer control.
PBAC Guard
// guards/pbac.guard.ts
import { CanActivateFn, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
import { inject } from '@angular/core';
import { PermissionService } from '../services/permission.service';
/**
* Route data interface for PBAC configuration.
*/
export interface PBACData {
requiredPermissions: string[];
requireAll?: boolean; // Default: true (all permissions required)
}
/**
* PBAC guard that checks specific permissions from route data.
*/
export const pbacGuard: CanActivateFn = (
route: ActivatedRouteSnapshot
): boolean | UrlTree => {
const permissions = inject(PermissionService);
const router = inject(Router);
const data = route.data as PBACData;
const requiredPermissions = data.requiredPermissions ?? [];
if (requiredPermissions.length === 0) {
return true;
}
const requireAll = data.requireAll ?? true;
const hasAccess = requireAll
? requiredPermissions.every(p => permissions.hasPermission(p))
: requiredPermissions.some(p => permissions.hasPermission(p));
return hasAccess
? true
: router.parseUrl('/unauthorized');
};
Route Configuration with PBAC
export const routes: Routes = [
{
path: 'users',
canActivate: [authGuard, pbacGuard],
data: { requiredPermissions: ['users:read'] } as PBACData,
loadComponent: () => import('./users/users.component')
},
{
path: 'users/create',
canActivate: [authGuard, pbacGuard],
data: { requiredPermissions: ['users:create'] } as PBACData,
loadComponent: () => import('./users/create-user.component')
},
{
path: 'reports',
canActivate: [authGuard, pbacGuard],
data: {
requiredPermissions: ['reports:read', 'reports:export'],
requireAll: true
} as PBACData,
loadComponent: () => import('./reports/reports.component')
}
];
Route Data for Configuration
Route data is a powerful way to configure guards without hardcoding values.
Typed Route Data
// models/route-data.model.ts
export interface RouteGuardData {
requiredRoles?: string[];
requiredPermissions?: string[];
requiredFeatures?: string[];
requireAll?: boolean;
redirectTo?: string;
}
// Type-safe route configuration
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard, rbacGuard, pbacGuard],
data: {
requiredRoles: ['admin'],
requiredPermissions: ['admin:access'],
redirectTo: '/unauthorized'
} as RouteGuardData,
loadComponent: () => import('./admin/admin.component')
}
];
Generic Guard with Route Data
// guards/generic.guard.ts
import { CanActivateFn, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
import { inject } from '@angular/core';
import { PermissionService } from '../services/permission.service';
import { RouteGuardData } from '../models/route-data.model';
/**
* Generic guard that reads all configuration from route data.
* Combines RBAC, PBAC, and feature flag checks.
*/
export const genericGuard: CanActivateFn = (
route: ActivatedRouteSnapshot
): boolean | UrlTree => {
const permissions = inject(PermissionService);
const router = inject(Router);
const data = route.data as RouteGuardData;
// Check roles
if (data.requiredRoles?.length) {
const hasRole = data.requireAll
? data.requiredRoles.every(r => permissions.hasRole(r))
: data.requiredRoles.some(r => permissions.hasRole(r));
if (!hasRole) {
return router.parseUrl(data.redirectTo ?? '/unauthorized');
}
}
// Check permissions
if (data.requiredPermissions?.length) {
const hasPermission = data.requireAll
? data.requiredPermissions.every(p => permissions.hasPermission(p))
: data.requiredPermissions.some(p => permissions.hasPermission(p));
if (!hasPermission) {
return router.parseUrl(data.redirectTo ?? '/unauthorized');
}
}
// Check features
if (data.requiredFeatures?.length) {
const hasFeature = data.requiredFeatures.every(f => permissions.hasFeature(f));
if (!hasFeature) {
return router.parseUrl(data.redirectTo ?? '/unauthorized');
}
}
return true;
};
Lazy Loading with Guards
Lazy loading and guards work together to create performant, secure routing.
Lazy Loaded Feature with Guards
// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
export const routes: Routes = [
{
path: 'dashboard',
canActivate: [authGuard],
loadChildren: () => import('./dashboard/dashboard.routes')
.then(m => m.DASHBOARD_ROUTES)
},
{
path: 'admin',
canMatch: [canMatchRole('admin')],
loadChildren: () => import('./admin/admin.routes')
.then(m => m.ADMIN_ROUTES)
}
];
Feature Routes with Child Guards
// admin/admin.routes.ts
import { Routes } from '@angular/router';
import { authChildGuard } from '../guards/auth-child.guard';
import { rbacGuard } from '../guards/rbac.guard';
export const ADMIN_ROUTES: Routes = [
{
path: '',
canActivateChild: [authChildGuard],
loadComponent: () => import('./admin-shell.component')
.then(m => m.AdminShellComponent),
children: [
{
path: 'users',
canActivate: [rbacGuard],
data: { requiredRoles: ['admin'] },
loadComponent: () => import('./users/users.component')
},
{
path: 'settings',
canActivate: [rbacGuard],
data: { requiredRoles: ['admin', 'superuser'] },
loadComponent: () => import('./settings/settings.component')
}
]
}
];
Standalone Routing with provideRouter
Modern Angular uses provideRouter() in the application config instead of RouterModule.forRoot().
Application Config
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding, withPreloading } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideRouter(
routes,
withComponentInputBinding(), // Bind route params to component inputs
withPreloading(/* preloading strategy */)
)
]
};
Main Entry Point
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { appConfig } from './app.config';
bootstrapApplication(AppComponent, appConfig)
.catch(err => console.error(err));
Route-Level Providers
Route-level providers allow you to inject services scoped to a specific route tree.
// admin/admin.routes.ts
import { Routes } from '@angular/router';
import { AdminService } from './services/admin.service';
export const ADMIN_ROUTES: Routes = [
{
path: '',
providers: [AdminService], // Scoped to admin routes only
loadComponent: () => import('./admin-shell.component'),
children: [
{
path: 'users',
loadComponent: () => import('./users/users.component')
}
]
}
];
Guards vs Interceptors
Understanding the difference is crucial for proper architecture.
| Aspect | Guards | Interceptors |
|---|---|---|
| Purpose | Navigation decisions | HTTP request/response handling |
| When it runs | Before route activation | Before/after HTTP calls |
| Can block navigation | Yes | No |
| Can modify requests | No | Yes |
| Use for auth | Navigation protection | Token attachment, refresh |
When to Use Guards
- Checking if user is authenticated
- Checking user roles/permissions
- Feature flag evaluation
- Unsaved changes confirmation
When to Use Interceptors
- Attaching auth tokens to requests
- Refreshing expired tokens
- Global error handling for HTTP
- Request/response logging
The Right Architecture
Guard (navigation decision)
โ
Interceptor (attach token)
โ
Backend (validate permissions)
โ
Response
Guards vs Backend Authorization
Frontend guards improve user experience and navigation. They do NOT replace backend authorization.
Why Both Are Necessary
| Layer | Purpose | Trust Level |
|---|---|---|
| Frontend Guards | UX, prevent unnecessary navigation, show/hide UI | Untrusted (can be bypassed) |
| Backend Authorization | Security, data protection, business logic validation | Trusted (source of truth) |
The Rule
- Frontend guards decide what the user sees
- Backend authorization decides what the user can do
Never assume that a frontend guard is sufficient for security. Always validate permissions on the server.
// โ Never do this
export const insecureGuard: CanActivateFn = () => {
// Checking localStorage is NOT secure
return localStorage.getItem('isAdmin') === 'true';
};
// โ
Do this instead
export const secureGuard: CanActivateFn = () => {
const auth = inject(AuthService);
// AuthService validates with the server
return auth.isAuthenticated();
};
Performance Considerations
Guards run during navigation, so their performance directly impacts user experience.
Keep Guards Fast
// โ
Fast: Synchronous check
export const fastGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isAuthenticated(); // Cached value, instant
};
// โ Slow: HTTP call in guard
export const slowGuard: CanActivateFn = () => {
const http = inject(HttpClient);
return http.get('/api/check-auth').pipe(
map(() => true),
catchError(() => of(false))
);
};
Avoid Heavy RxJS Pipelines
// โ Heavy pipeline
export const heavyGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const permissions = inject(PermissionService);
return auth.getUser().pipe(
switchMap(user => permissions.getForUser(user.id)),
map(perms => perms.includes('admin')),
distinctUntilChanged(),
shareReplay(1),
catchError(() => of(false))
);
};
// โ
Lightweight
export const lightGuard: CanActivateFn = () => {
const permissions = inject(PermissionService);
return permissions.isAdmin(); // Direct signal read
};
Cache Permissions
@Injectable({ providedIn: 'root' })
export class PermissionService {
private permissions$?: Observable<PermissionSet>;
fetchPermissions(): Observable<PermissionSet> {
if (this.permissions$) {
return this.permissions$; // Return cached observable
}
this.permissions$ = this.http.get<PermissionSet>('/api/permissions').pipe(
shareReplay(1) // Cache the result
);
return this.permissions$;
}
}
Preload Guards
Consider preloading permissions during app initialization:
// app.config.ts
import { APP_INITIALIZER } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_INITIALIZER,
useFactory: (permissions: PermissionService) => () =>
permissions.fetchPermissions().toPromise(),
deps: [PermissionService],
multi: true
}
]
};
Navigation UX Best Practices
Clear Redirects
Always redirect to meaningful pages, not generic error pages.
export const authGuard: CanActivateFn = (route): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
// Redirect to login with context
const returnUrl = route.url.map(s => s.path).join('/');
return router.parseUrl(`/login?returnUrl=${encodeURIComponent(returnUrl)}`);
};
Loading States
For async guards, show loading indicators:
// app.component.ts
@Component({
selector: 'app-root',
template: `
@if (router.events | async; as event) {
@if (event instanceof NavigationStart) {
<app-loading-spinner />
}
}
<router-outlet />
`,
standalone: true,
imports: [RouterOutlet, RouterModule, LoadingSpinnerComponent]
})
export class AppComponent {
readonly router = inject(Router);
}
Graceful Degradation
export const safeGuard: CanActivateFn = (): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
try {
return auth.isAuthenticated()
? true
: router.parseUrl('/login');
} catch (error) {
console.error('Guard error:', error);
return router.parseUrl('/error');
}
};
Error Handling in Guards
Guards should handle errors gracefully to prevent navigation from breaking.
// guards/error-safe.guard.ts
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
/**
* Guard with comprehensive error handling.
* Never lets navigation fail silently.
*/
export const errorSafeGuard: CanActivateFn = (): Observable<boolean | UrlTree> => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.checkAuthentication().pipe(
map(isAuth => {
if (isAuth) {
return true;
}
return router.parseUrl('/login');
}),
catchError(error => {
console.error('Authentication check failed:', error);
// Redirect to error page on failure
return of(router.parseUrl('/error'));
})
);
};
Testing Guards
Guards should be tested in isolation with mocked services.
Unit Test for Auth Guard
// guards/auth.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { Router, UrlTree } from '@angular/router';
import { authGuard } from './auth.guard';
import { AuthService } from '../services/auth.service';
describe('authGuard', () => {
let authService: jasmine.SpyObj<AuthService>;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
authService = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
router = jasmine.createSpyObj('Router', ['parseUrl']);
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: authService },
{ provide: Router, useValue: router }
]
});
});
it('should allow navigation when authenticated', () => {
authService.isAuthenticated.and.returnValue(true);
const result = TestBed.runInInjectionContext(() => authGuard());
expect(result).toBe(true);
});
it('should redirect to login when not authenticated', () => {
authService.isAuthenticated.and.returnValue(false);
const loginUrlTree = {} as UrlTree;
router.parseUrl.and.returnValue(loginUrlTree);
const result = TestBed.runInInjectionContext(() => authGuard());
expect(router.parseUrl).toHaveBeenCalledWith('/login');
expect(result).toBe(loginUrlTree);
});
});
Unit Test for Permission Guard
// guards/permission.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
import { permissionGuard } from './permission.guard';
import { PermissionService } from '../services/permission.service';
describe('permissionGuard', () => {
let permissionService: jasmine.SpyObj<PermissionService>;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
permissionService = jasmine.createSpyObj('PermissionService', ['hasPermission']);
router = jasmine.createSpyObj('Router', ['parseUrl']);
TestBed.configureTestingModule({
providers: [
{ provide: PermissionService, useValue: permissionService },
{ provide: Router, useValue: router }
]
});
});
it('should allow navigation when permission is granted', () => {
permissionService.hasPermission.and.returnValue(true);
const guard = permissionGuard('users:read');
const result = TestBed.runInInjectionContext(() =>
guard({} as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
it('should redirect when permission is denied', () => {
permissionService.hasPermission.and.returnValue(false);
const unauthorizedUrlTree = {} as UrlTree;
router.parseUrl.and.returnValue(unauthorizedUrlTree);
const guard = permissionGuard('users:read');
const result = TestBed.runInInjectionContext(() =>
guard({} as ActivatedRouteSnapshot, {} as any)
);
expect(router.parseUrl).toHaveBeenCalledWith('/unauthorized');
expect(result).toBe(unauthorizedUrlTree);
});
});
Testing with Signals
// guards/admin.guard.spec.ts
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Router, UrlTree } from '@angular/router';
import { adminGuard } from './admin.guard';
import { PermissionService } from '../services/permission.service';
describe('adminGuard', () => {
let permissionService: PermissionService;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
router = jasmine.createSpyObj('Router', ['parseUrl']);
TestBed.configureTestingModule({
providers: [
PermissionService,
{ provide: Router, useValue: router }
]
});
permissionService = TestBed.inject(PermissionService);
});
it('should allow navigation for admin users', () => {
permissionService.setUser({
id: '1',
name: 'Admin User',
roles: ['admin'],
permissions: ['admin:access']
});
const result = TestBed.runInInjectionContext(() => adminGuard());
expect(result).toBe(true);
});
it('should redirect non-admin users', () => {
permissionService.setUser({
id: '2',
name: 'Regular User',
roles: ['user'],
permissions: ['users:read']
});
const unauthorizedUrlTree = {} as UrlTree;
router.parseUrl.and.returnValue(unauthorizedUrlTree);
const result = TestBed.runInInjectionContext(() => adminGuard());
expect(router.parseUrl).toHaveBeenCalledWith('/unauthorized');
expect(result).toBe(unauthorizedUrlTree);
});
});
Common Mistakes
1. Business Logic in Guards
// โ Guard doing too much
export const badGuard: CanActivateFn = () => {
const http = inject(HttpClient);
const store = inject(Store);
return http.get('/api/permissions').pipe(
tap(perms => store.dispatch(setPermissions({ perms }))),
map(perms => perms.includes('admin')),
catchError(() => {
store.dispatch(showError());
return of(false);
})
);
};
// โ
Guard stays focused
export const goodGuard: CanActivateFn = () => {
const permissions = inject(PermissionService);
return permissions.isAdmin(); // Service handles the complexity
};
2. Direct HTTP Calls
// โ HTTP call in guard
export const apiGuard: CanActivateFn = () => {
const http = inject(HttpClient);
return http.get('/api/check-auth').pipe(map(() => true));
};
// โ
Use cached service
export const cachedGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isAuthenticated(); // Already cached
};
3. Imperative Navigation
// โ Imperative navigation
export const imperativeGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isAuthenticated()) {
router.navigate(['/login']); // Side effect!
return false;
}
return true;
};
// โ
Declarative UrlTree
export const declarativeGuard: CanActivateFn = (): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated()
? true
: router.parseUrl('/login');
};
4. Ignoring UrlTree
// โ Returning false without redirect
export const falseGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isAuthenticated(); // Returns false, user stays on current page
};
// โ
Redirect to meaningful page
export const redirectGuard: CanActivateFn = (): boolean | UrlTree => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated()
? true
: router.parseUrl('/login');
};
5. Class-Based Guards for New Code
// โ Legacy class guard
@Injectable({ providedIn: 'root' })
export class OldGuard implements CanActivate {
constructor(private auth: AuthService) {}
canActivate() { return this.auth.isAuthenticated(); }
}
// โ
Modern functional guard
export const modernGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isAuthenticated();
};
6. No Backend Validation
// โ Frontend-only "security"
export const insecureGuard: CanActivateFn = () => {
return localStorage.getItem('role') === 'admin';
};
// โ
Backend-validated
export const secureGuard: CanActivateFn = () => {
const auth = inject(AuthService); // Validates with server
return auth.isAuthenticated() && auth.hasRole('admin');
};
7. Untested Guards
// Always test your guards!
// See "Testing Guards" section above for examples
Production Checklist
Before deploying guards to production, verify:
- [ ] All guards are under 20 lines of logic
- [ ] Business logic is extracted to dedicated services
- [ ] Functional guards (
CanActivateFn,CanMatchFn) are used - [ ]
UrlTreeis returned for redirects, not imperative navigation - [ ]
inject()is used for dependency injection - [ ] Signals are read directly (no subscriptions in guards)
- [ ] Permission logic is centralized in a service
- [ ] Feature flags are integrated where appropriate
- [ ] Backend authorization is implemented and validated
- [ ] All guards have unit tests with mocked services
- [ ] Route data is used for configuration instead of hardcoding
- [ ] Lazy loading is configured for feature modules
- [ ]
provideRouter()is used in application config - [ ] Error handling is implemented in async guards
- [ ] Performance metrics are monitored for navigation timing
- [ ] Guards handle edge cases (null users, missing permissions, etc.)
- [ ] Redirect URLs include context (returnUrl parameters)
- [ ] Loading states are shown during async guard evaluation
- [ ] Guards are documented with JSDoc comments
- [ ] Security review confirms no frontend-only authorization
Enterprise Routing Insights
Scaling Beyond 200 Routes
In enterprise Angular applications with 200+ routes, guard complexity compounds quickly. A recurring routing pattern I see in production codebases is guards that:
- Make direct HTTP calls to permission endpoints
- Cache permissions locally (often incorrectly)
- Handle token refresh logic inline
- Navigate imperatively instead of returning UrlTree
Each of these is a maintainability challenge that grows with your application.
The Enterprise Pattern
App Shell
โโโ Auth Guard (global)
โโโ Feature Module A
โ โโโ Feature Guard
โ โโโ RBAC Guard
โ โโโ Child Routes
โโโ Feature Module B
โ โโโ Feature Flag Guard
โ โโโ Child Routes
โโโ Admin Module
โโโ Admin Guard
โโโ RBAC Guard
โโโ Child Routes
Team Guidelines
- Guard size limit โ 20 lines max
- Service ownership โ Permission service owns all auth logic
- No HTTP in guards โ All API calls go through services
-
UrlTree only โ No
router.navigate()in guards - Test coverage โ 100% guard test coverage required
- Documentation โ JSDoc for every guard explaining its purpose
Conclusion
Modern Angular guards are lightweight, composable, and focused on routing decisionsโnot business logic. The best Angular guards are small, predictable, and only answer one question: "Should navigation continue?"
Key Takeaways:
- Use functional guards (
CanActivateFn,CanMatchFn) for all new code - Keep guards under 20 lines โ extract complexity to services
- Return
UrlTreefor redirects, never use imperative navigation - Read Signals directly for reactive permission state
- Centralize permission logic in a dedicated service
- Use
CanMatchoverCanLoadfor modern applications - Always validate permissions on the backend
- Test guards in isolation with mocked services
The Golden Rule:
"A guard should decide navigationโnot orchestrate your application."
Discussion
What responsibility do you think Angular guards should never have? Drop your thoughts in the comments below! ๐
#Angular #AngularRouting #FrontendArchitecture #EnterpriseAngular #WebDevelopment #SoftwareArchitecture #TypeScript #AngularDevelopers #CodeQuality #ProgrammingMasteryAcademy
I write about Angular architecture, enterprise UI patterns, and frontend best practices at Programming Mastery Academy โ follow along for more breakdowns like this one.
๐ More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
๐ Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:
๐ LinkedIn โ Professional discussions, architecture breakdowns, and engineering insights.
๐ธ Instagram โ Visuals, carousels, and designโdriven posts under the Terminal Elite aesthetic.
๐ง Website โ Articles, tutorials, and project showcases.
๐ฅ YouTube โ Deepโdive videos and live coding sessions.
Top comments (0)