An Angular route has canActivate: [authGuard, adminGuard]; the admin HTTP check fires even for signed-out users, and some non-admins still get in; what explains both?
answer
- array order is priority, not sequence
- all guards in one array start together
- invalid results are silently ignored
- only the first emission counts
- parent route guards finish first
basics
~20 sAngular invokes every guard in one canActivate array together and uses array order only to rank verdicts, so adminGuard's request always starts. It also ignores results other than boolean, UrlTree or RedirectCommand, so an unmapped response object passes.
solid answer
~50 sFor one route, the router wraps each guard in `defer`, subscribes to all of them together and reduces their first values in array order: the first non-`true` result wins once every guard before it has returned `true`. So `adminGuard` runs, and fires its request, even when `authGuard` is about to say no; array order decides priority, not execution. Second, values other than `true`, `false`, a `UrlTree` or a `RedirectCommand` are skipped as invalid: if `adminGuard` returns `http.get<any>('/api/me')` and the API answers `{ isAdmin: false }`, that object is ignored and, with the other guard passing, the navigation continues. Fixes: map the response explicitly to `true` or a `UrlTree`; type guards as `CanActivateFn` and avoid `any`; sequence dependent checks inside one guard, or move `authGuard` to the parent route, whose `canActivate` completes before the child's guards start; and handle HTTP errors, which otherwise end the navigation with a `NavigationError`.
code
ts · 36 linesimport { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { CanActivateFn, Router, Routes } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from './auth.service';
interface Me {
isAdmin: boolean;
}
export const authGuard: CanActivateFn = () =>
inject(AuthService).isSignedIn() ? true : inject(Router).createUrlTree(['/login']);
export const adminGuard: CanActivateFn = () => {
const router = inject(Router);
return inject(HttpClient)
.get<Me>('/api/me')
.pipe(
map((me) => (me.isAdmin ? true : router.createUrlTree(['/forbidden']))),
catchError(() => of(router.createUrlTree(['/login']))),
);
};
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard], // completes before any child guard starts
children: [
{
path: 'users',
canActivate: [adminGuard],
loadComponent: () => import('./admin/user-list').then((m) => m.AdminUserList),
},
],
},
];go deeper
Recall that a route can list several guards in canActivate and that each must return true, false, a UrlTree or a RedirectCommand.
Explain that guards in one array start together, that array order only ranks verdicts, and that the first emission of each is used.
Diagnose silent pass-through from unmapped results and wasted requests from concurrent guards, and fix them with explicit mapping, typing and parent-level sequencing.
Set conventions that make guard results type-checked and dependencies explicit, so authorization UX bugs are caught in review rather than in production.
## The symptoms An admin route is configured as `canActivate: [authGuard, adminGuard]`. Two things go wrong in production: 1. Signed-out users trigger the admin-check request, which fails with 401 and pollutes logs. 2. Some signed-in non-admins reach the admin page. Both follow from how Angular's router evaluates guard results, and neither is visible from reading the route table. ## How one guard array is evaluated For each route being activated, the router maps every entry of `canActivate` to a deferred Observable: the guard function is called inside `runInInjectionContext` and its result is wrapped into an Observable and reduced with `first()`. It then subscribes to **all of them together** (a `combineLatest` in its internal `prioritizedGuardValue` operator) and inspects the results in array order: - If a guard has not produced a value yet, evaluation waits. - If the result is `true`, evaluation moves to the next guard. - If the result is `false`, a `UrlTree` or a `RedirectCommand`, that result becomes the verdict immediately, without waiting for guards later in the array. - **Any other value is ignored**, as if that guard had not objected. So array order is a **priority order for verdicts**, not an execution order. `adminGuard` is invoked at the same moment as `authGuard`, which is why its request fires for signed-out users. A redirect from the second guard also waits for the first guard to settle before it can win. | Guard results (auth, admin) | Verdict | |---|---| | `true`, `true` | Navigation continues | | `UrlTree('/login')`, still pending | Redirect to `/login` at once | | pending, `false` | Waits for `authGuard`, then its result or `false` | | `true`, `{ isAdmin: false }` | Navigation continues: the object is ignored | ## Why non-admins get in The typical `adminGuard` looked like `() => inject(HttpClient).get<any>('/api/me')`. `any` satisfies the `CanActivateFn` return type, so the compiler is silent. At runtime the router receives `{ isAdmin: false }`, which is not a valid guard result, and skips it. With `authGuard` returning `true`, every guard has either passed or been ignored, and the navigation proceeds. Related traps: - A guard reading a stream that replays a cached or placeholder value first (for example a `BehaviorSubject` seeded with a stale role) is decided by that first value, because only the first emission counts. - A stream that never emits leaves the navigation pending forever. - An HTTP error inside the guard ends the navigation with a `NavigationError` instead of a redirect. ## Why this is easy to miss - The Angular routing guide says guards "are executed in the order they appear in the array", which reads like a sequence; the source shows the calls are started together and only the verdicts are ordered. - Unit tests usually call each guard in isolation, so the combined behaviour of the array is never exercised. - The permissive failure mode produces no error or warning: an ignored value looks exactly like a passing guard in the event stream. - Local development often runs as an admin, so the non-admin path is rarely clicked. ## Fixes 1. **Return only valid results.** Map explicitly: `map((me) => me.isAdmin ? true : router.createUrlTree(['/forbidden']))`, with `catchError(() => of(router.createUrlTree(['/login'])))`. 2. **Type honestly.** Declare guards as `CanActivateFn` and give HTTP calls a real response type instead of `any`, so a raw object result becomes a compile error. 3. **Sequence dependent checks.** Either write one guard that checks authentication, then the role (an `async` function that injects first and then awaits each step, or an Observable chain with `switchMap`), or lift `authGuard` to the parent route. 4. **Use route levels for order.** The router processes routes being activated top-down and one at a time: a parent's `canActivate` completes before its children's `canActivateChild` and `canActivate` guards start, and a non-`true` result stops the chain. Putting `authGuard` on the `admin` parent and `adminGuard` on the children guarantees the role check only runs for signed-in users. ## Checking the fix - Watch the network panel on a signed-out navigation: no admin request should fire. - Navigate as a non-admin and confirm the app lands on `/forbidden` rather than the admin page. - Keep the server's authorization check; the guard only improves the experience.
- In Angular, if a canActivate array's first guard is still pending and the second returns a UrlTree, when does the redirect happen?Only after the first guard settles. The router reads results in array order, so a later guard's verdict waits for every earlier guard to return `true`. If the first guard returns `false` or its own redirect, that result wins and the second guard's `UrlTree` is never used.
- In Angular, how do canActivateChild guards on a parent fit into this ordering?For each route being activated, top-down, the router first evaluates the `canActivateChild` guards of that route's ancestors, ranking their verdicts nearest parent first, and only then the route's own `canActivate`. The parent's own `canActivate` was evaluated as an earlier step, so it settles before any child-level check begins.
The guards in one array are a panel of judges who all start deliberating at the same moment, but the clerk reads the verdicts in seating order, and ignores any card that is not a clear yes, no or redirect.
saying these in an interview costs you the question
- Guards in one canActivate array run strictly one after another in array order.
- A guard that returns undefined blocks the navigation.
- Returning the HTTP response object is fine because the router reads its fields.
- If an earlier guard redirects, later guards in the same array are never invoked.
- Putting authGuard first in the array is enough to skip the admin request for signed-out users.