Angular runs multiple route guards in parallel by default — great for performance, not so great when guards depend on each other. If your
authGuard
needs to finish beforepermissionGuard
runs, you're out of luck... unless you control the execution flow. In this article, we walk through a clean, reusable utility that forces guards to run sequentially, ensuring dependent logic behaves predictably — all without giving up type safety or Angular's DI.
1. Introduction
In Angular, when multiple route guards like CanActivate
or CanMatch
are assigned to a route, they are executed in parallel. While this works well in many cases, it can cause issues when guards have dependencies on each other — for example, when one guard must ensure authentication before another checks user permissions. In such scenarios, executing guards in sequence becomes essential. Fortunately, this behavior can be customized with a simple utility function that ensures guards run one after another, giving you more control over route access logic.
2. Solution 🧠
To address the challenge of dependent guard execution, a lightweight utility function can be introduced to chain guards sequentially. Instead of relying on Angular’s default parallel evaluation, this utility ensures that each guard runs only if the previous one has allowed navigation to continue. It works seamlessly with both CanActivate
and CanMatch
guards, preserving type safety and supporting async operations. With this approach, complex access control logic that depends on prior checks — such as authentication before permission validation — becomes predictable and maintainable.
2.1 Guard utility
The utility is designed to support both CanActivateFn
and CanMatchFn
, making it versatile enough to cover all standard routing guard scenarios in Angular. Whether you're guarding access to a route (CanActivate
) or determining if a route should even match the URL (CanMatch
), the same chaining logic applies seamlessly. This unified approach ensures consistent behavior and simplifies guard composition across your entire routing configuration.
guard.util.ts
import { inject, Injector, runInInjectionContext } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
CanMatchFn,
GuardResult,
MaybeAsync,
Route,
RouterStateSnapshot,
UrlSegment
} from '@angular/router';
import { from, isObservable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
export function chainActivateGuards(...guards: CanActivateFn[]): CanActivateFn {
return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): MaybeAsync<GuardResult> => {
return chainGuards((guard) => guard(route, state), guards);
};
}
export function chainMatchGuards(...guards: CanMatchFn[]): CanMatchFn {
return (route: Route, segments: UrlSegment[]): MaybeAsync<GuardResult> => {
return chainGuards((guard) => guard(route, segments), guards);
};
}
function chainGuards<T>(applyGuard: (guard: T) => MaybeAsync<GuardResult>, guards: T[]): MaybeAsync<GuardResult> {
const injectionContext = inject(Injector);
return guards.reduce(
(acc, guard) =>
acc.pipe(
switchMap((result) =>
result === true ? runInInjectionContext(injectionContext, () => toObservable(applyGuard(guard))) : of(result)
)
),
of(true)
);
}
function toObservable<T>(input: MaybeAsync<T>) {
return isObservable(input) ? input : input instanceof Promise ? from(input) : of(input);
}
2.2 Guards
It’s best practice to declare each guard as an atomic, single-responsibility function that handles one specific check — for example, authentication, permission, or feature flag validation. This modular approach makes guards easier to maintain, test, and reuse. Then, you can compose these atomic guards in the desired sequence using the chaining utility, keeping your logic clear and flexible.
auth-activate.guard.ts
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
import { NotificationService } from '@shared/components/notification/notification.service';
import { AbilityFactory } from '@shared/factories/ability.factory';
import { AccountService } from '@shared/stores/account/account.service';
import { TokenRepository } from '@shared/stores/token/token.repository';
import { map, of, switchMap, take } from 'rxjs';
export const authActivateGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): MaybeAsync<GuardResult> => {
const tokenRepository: TokenRepository = inject(TokenRepository);
const accountService: AccountService = inject(AccountService);
const abilityFactory: AbilityFactory = inject(AbilityFactory);
const notification: NotificationService = inject(NotificationService);
const subject = route.data['ability']?.subject;
const denied = `Access denied to "${state.url}".`;
const authCheck$ = tokenRepository.isLoggedIn$.pipe(take(1));
return authCheck$.pipe(
switchMap((isLoggedIn) => {
if (isLoggedIn) {
const abilityCheck$ = subject ? abilityFactory.checkAbility(subject).pipe(take(1)) : of(true);
return abilityCheck$.pipe(
map((canAccess) => {
if (canAccess) {
return true;
} else {
logWarning(denied, 'User has insufficient privileges.');
showError('App.Forbidden');
accountService.resetStores();
return accountService.pointToForbidden();
}
})
);
} else {
logWarning(denied, 'User is not logged in.');
showError('App.LoginRequired');
accountService.resetStores();
return of(accountService.pointToLogin());
}
})
);
function logWarning(warning: string, details: string) {
console.warn(`${warning} ${details}`);
}
function showError(error: string) {
notification.error(error);
}
};
company-activate.guard.ts
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
import { CompanySelectService } from '@layout/auth/content/organizations/company/company-select/company-select.service';
export const companyActivateGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): MaybeAsync<GuardResult> => {
const companySelectService: CompanySelectService = inject(CompanySelectService);
return companySelectService.selectCompany();
};
country-activate.guard.ts
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
import { CountrySelectService } from '@layout/auth/content/settings/country/country-select/country-select.service';
export const countryActivateGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): MaybeAsync<GuardResult> => {
const countrySelectService: CountrySelectService = inject(CountrySelectService);
return countrySelectService.selectCountry();
};
office-activate.guard.ts
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
import { OfficeSelectService } from '@layout/auth/content/organizations/office/office-select/office-select.service';
export const officeActivateGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): MaybeAsync<GuardResult> => {
const officeSelectService: OfficeSelectService = inject(OfficeSelectService);
return officeSelectService.selectOffice();
};
3. Implementation
To use the utility in your routing configuration, simply pass your guards in the desired execution order to either chainActivateGuards
or chainMatchGuards
. These helper functions ensure your guards run sequentially, respecting the flow you define. Here's how you might apply it directly in your Routes
array:
organization-routes.ts
import { Routes } from '@angular/router';
import { authActivateGuard } from '@core/guards/auth-activate.guard';
import { companyActivateGuard } from '@core/guards/company-activate.guard';
import { countryActivateGuard } from '@core/guards/country-activate.guard';
import { officeActivateGuard } from '@core/guards/office-activate.guard';
import { RouteDataResolver } from '@core/resolvers/route-data.resolver';
import { chainActivateGuards } from '@core/utils/guard.utils';
import { CompanyListComponent } from './company/company-list/company-list.component';
import { OfficeListComponent } from './office/office-list/office-list.component';
import { WorkingHourListComponent } from './working-hour/working-hour-list/working-hour-list.component';
export const ORGANIZATIONS_ROUTES: Routes = [
{
path: 'companies',
component: CompanyListComponent,
canActivate: [authActivateGuard],
data: {
section: 'Organizations.Title',
title: 'Organizations.Company.Title',
subtitle: 'Organizations.Company.Subtitle',
ability: {
subject: 'Company'
}
},
resolve: { url: RouteDataResolver }
},
{
path: 'offices',
component: OfficeListComponent,
canActivate: [chainActivateGuards(authActivateGuard, companyActivateGuard, countryActivateGuard)],
data: {
section: 'Organizations.Title',
title: 'Organizations.Office.Title',
subtitle: 'Organizations.Office.Subtitle',
ability: {
subject: 'Office'
}
},
resolve: { url: RouteDataResolver }
},
{
path: 'working-hours',
component: WorkingHourListComponent,
canActivate: [chainActivateGuards(authActivateGuard, companyActivateGuard, officeActivateGuard)],
data: {
section: 'Organizations.Title',
title: 'Organizations.WorkingHour.Title',
subtitle: 'Organizations.WorkingHour.Subtitle',
ability: {
subject: 'WorkingHour'
}
},
resolve: { url: RouteDataResolver }
},
{ path: '', redirectTo: 'companies', pathMatch: 'full' }
];
4. Conclusion
Angular’s default behavior of running multiple guards in parallel can lead to unexpected issues when guard execution depends on the outcome of previous checks. By introducing the chainActivateGuards
and chainMatchGuards
utility functions, we gain precise control over guard sequencing, ensuring each guard only runs if all prior guards have allowed navigation to proceed. This simple yet powerful pattern enhances guard reliability and maintainability without sacrificing Angular’s reactive and asynchronous nature. Incorporating these chained guards into your Angular applications can greatly simplify complex authorization flows and improve the overall user experience.
One challenge when creating reusable guard utilities in Angular is managing dependency injection outside of class-based guards. Since standalone guard functions can’t use constructor injection, accessing services like authentication or notifications can be difficult. Fortunately, Angular provides a solution through the Injector
and the runInInjectionContext
function. By injecting the current Injector
and executing logic within its context, guard functions can access Angular services using the inject()
function — just like they would in a class. This makes it possible to build flexible utilities like chainActivateGuards
and chainMatchGuards
that execute guards sequentially while maintaining full access to dependency injection. The result is a clean, modular, and testable approach to composing guards with complex service requirements.
Top comments (0)