ould never contain logic. They should delegate to a service that encapsulates identity, permissions, and feature flags. This service acts as the single source of truth for access decisions.
// core/access/route-access-policy.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { IdentityVault } from './identity-vault.service';
import { FeatureRegistry } from './feature-registry.service';
export interface AccessRequirement {
roles?: string[];
permissions?: string[];
featureFlags?: string[];
}
@Injectable({ providedIn: 'root' })
export class RouteAccessPolicy {
private identity = inject(IdentityVault);
private features = inject(FeatureRegistry);
// Computed signal for reactive access state
readonly userRoles = computed(() => this.identity.profile()?.roles ?? []);
readonly userPermissions = computed(() => this.identity.profile()?.permissions ?? []);
hasRole(role: string): boolean {
return this.userRoles().includes(role);
}
hasPermission(permission: string): boolean {
return this.userPermissions().includes(permission);
}
isFeatureActive(flag: string): boolean {
return this.features.isEnabled(flag);
}
evaluate(requirements: AccessRequirement): boolean {
const { roles, permissions, featureFlags } = requirements;
if (roles && !roles.some(r => this.hasRole(r))) {
return false;
}
if (permissions && !permissions.every(p => this.hasPermission(p))) {
return false;
}
if (featureFlags && !featureFlags.every(f => this.isFeatureActive(f))) {
return false;
}
return true;
}
}
2. Functional Guard Factories
We create factory functions that return guard functions. This pattern allows dynamic configuration via route data or direct parameters, promoting reusability.
// routing/guards/access-gate.guard.ts
import { CanActivateFn, CanMatchFn, UrlTree, Router } from '@angular/router';
import { inject } from '@angular/core';
import { RouteAccessPolicy, AccessRequirement } from '../../core/access/route-access-policy.service';
export function createAccessGate(requirements: AccessRequirement): CanActivateFn {
return (): boolean | UrlTree => {
const policy = inject(RouteAccessPolicy);
const router = inject(Router);
if (policy.evaluate(requirements)) {
return true;
}
// Return UrlTree for safe, router-managed redirects
return router.parseUrl('/access-denied');
};
}
export function createFeatureGate(flag: string): CanMatchFn {
return (): boolean => {
const policy = inject(RouteAccessPolicy);
return policy.isFeatureActive(flag);
};
}
3. Composition Utility
Complex routes often require multiple checks. A composition utility allows combining guards without nesting or repetition.
// routing/guards/composition.util.ts
import { CanActivateFn, CanMatchFn, UrlTree } from '@angular/router';
type GuardFn = CanActivateFn | CanMatchFn;
export function composeGuards(...guards: GuardFn[]): GuardFn {
return (...args: any[]): boolean | UrlTree => {
for (const guard of guards) {
const result = guard(...args);
// If any guard returns a UrlTree, short-circuit and return it
if (result instanceof UrlTree) {
return result;
}
// If any guard returns false, block navigation
if (result === false) {
return false;
}
}
return true;
};
}
4. Unsaved Changes Protection
For CanDeactivate, we use a generic interface to allow components to signal dirty state.
// routing/guards/dirty-form.guard.ts
import { CanDeactivateFn } from '@angular/router';
import { Observable } from 'rxjs';
export interface DirtyFormAware {
isDirty: () => boolean | Observable<boolean> | Promise<boolean>;
}
export const preventDirtyExit: CanDeactivateFn<DirtyFormAware> = (component) => {
if (!component || !component.isDirty) {
return true;
}
const isDirty = component.isDirty();
if (isDirty === false) {
return true;
}
// Synchronous confirmation for simplicity;
// production apps may use a modal service returning an Observable
if (typeof isDirty === 'boolean') {
return confirm('You have unsaved changes. Discard them?');
}
// Handle async dirty state
return new Promise<boolean>((resolve) => {
Promise.resolve(isDirty).then(dirty => {
if (!dirty) {
resolve(true);
} else {
resolve(confirm('You have unsaved changes. Discard them?'));
}
});
});
};
5. Route Configuration
Modern Angular uses provideRouter with standalone components. Guards are applied declaratively.
// app.routes.ts
import { Routes } from '@angular/router';
import { createAccessGate, createFeatureGate } from './routing/guards/access-gate.guard';
import { preventDirtyExit } from './routing/guards/dirty-form.guard';
import { composeGuards } from './routing/guards/composition.util';
export const appRoutes: Routes = [
{
path: 'dashboard',
// CanMatch prevents chunk download if user lacks 'viewer' role
canMatch: [createAccessGate({ roles: ['viewer'] })],
loadComponent: () => import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent),
children: [
{
path: 'settings',
// Composed guard for nested route
canActivate: [
composeGuards(
createAccessGate({ roles: ['admin'] }),
createFeatureGate('advanced-settings')
)
],
loadComponent: () => import('./features/settings/settings.component')
.then(m => m.SettingsComponent)
}
]
},
{
path: 'editor',
canActivate: [createAccessGate({ permissions: ['edit-content'] })],
canDeactivate: [preventDirtyExit],
loadComponent: () => import('./features/editor/editor.component')
.then(m => m.EditorComponent)
}
];
Pitfall Guide
-
The Redirect Loop via Imperative Navigation
- Explanation: Calling
router.navigate() inside a guard can trigger the guard again, causing an infinite loop or navigation error.
- Fix: Always return
UrlTree from router.parseUrl(). The router handles UrlTree returns safely without re-evaluating the current guard.
-
Using CanMatch for Data Fetching
- Explanation:
CanMatch is evaluated before the route is matched. If you perform async data fetching here, you block the router pipeline and degrade UX.
- Fix: Use
CanMatch only for synchronous checks like permissions or feature flags. Use Resolve guards for data fetching.
-
Client-Side Security Illusion
- Explanation: Guards only protect the UI. They do not secure API endpoints. A malicious user can bypass guards by calling APIs directly.
- Fix: Guards are for UX and bundle optimization. Always enforce authorization on the backend. Treat client-side guards as a convenience layer, not a security boundary.
-
Signal Hydration Race Conditions
- Explanation: If a guard reads a signal that hasn't been hydrated (e.g., user profile loaded asynchronously), the guard may make a decision based on empty state.
- Fix: Ensure guards wait for critical state hydration or return an
Observable that emits only after the state is stable. Alternatively, use route resolvers to load identity data before guards run.
-
Blocking the Router with Heavy Async Operations
- Explanation: Returning a slow
Observable or Promise from a guard freezes the navigation UI. Users see a blank screen while waiting.
- Fix: Keep guards fast. If a check requires a network call, consider pre-fetching data or using a loading indicator. Prefer synchronous checks where possible.
-
Ignoring CanMatch for Lazy Modules
- Explanation: Using
CanActivate on a lazy route downloads the chunk before checking permissions. This wastes bandwidth and exposes code structure.
- Fix: Use
CanMatch for lazy-loaded routes with permission requirements. This prevents the HTTP request for the chunk entirely.
-
Tight Coupling in Guard Logic
- Explanation: Embedding
HttpClient or complex business logic inside a guard makes the guard hard to test and maintain.
- Fix: Delegate to services. The guard should only call
inject(Service).check() and return the result.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Lazy-loaded route with role check | CanMatch + UrlTree | Prevents chunk download; saves bandwidth; secure | Low dev cost; High perf gain |
| Non-lazy route with auth check | CanActivate + UrlTree | Standard protection; CanMatch not applicable | Low dev cost |
| Route requires data before render | Resolve Guard | Fetches data before activation; avoids blank states | Moderate dev cost |
| Multiple complex conditions | composeGuards | Improves readability; enables reuse | Low dev cost |
| Unsaved changes warning | CanDeactivate + Interface | Component-level control; flexible UX | Low dev cost |
Configuration Template
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
appRoutes,
withComponentInputBinding() // Enables route data binding to component inputs
)
]
};
// app.routes.ts (Snippet for dynamic data usage)
import { createAccessGate } from './routing/guards/access-gate.guard';
export const appRoutes: Routes = [
{
path: 'admin',
// Guards can read route data for dynamic configuration
canActivate: [
(route) => {
const requiredRole = route.data['requiredRole'] as string;
return createAccessGate({ roles: [requiredRole] })();
}
],
data: { requiredRole: 'super-admin' },
loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent)
}
];
Quick Start Guide
- Create the Policy Service: Implement
RouteAccessPolicy to centralize role, permission, and feature flag checks.
- Define Guard Factories: Write
createAccessGate and createFeatureGate functions that return CanActivateFn or CanMatchFn.
- Add Composition Helper: Implement
composeGuards to combine multiple checks.
- Configure Routes: Apply guards to routes using
canActivate or canMatch. Use UrlTree for redirects.
- Test: Write unit tests for guards by mocking
RouteAccessPolicy and asserting return values.