Why Isn’t My Angular Route Guard Firing? canMatch, canActivate and More [2026]
You add an auth guard, log in as a regular user, type /admin/reports into the address bar, and the page loads anyway. Or the opposite: the guard ran once, the user's role changed, and navigating to ?tab=billing still shows the page because the guard never ran again. Route guards are short functions, but when the router calls them depends on which guard type you used, where it sits in the route tree, and what changed in the URL. Most "my guard isn't firing" bugs are one of those three.
This is lesson 10.7 of the Angular Tutorial. It follows lesson 10.6 on race conditions and builds on lesson 5.3, which introduced functional guards. Here you'll learn exactly when each guard type runs, why guards on lazy routes and parent routes behave differently than you might expect, how runGuardsAndResolvers controls re-running, and how to debug async guards and redirects.
The four guard types and when they run #
| Guard | Asked during | Question it answers | If it rejects |
|---|---|---|---|
canMatch |
Route matching, before the route is chosen (and before lazy code loads) | "Should this route config be considered at all?" | The router tries the next matching route; if none match, the navigation fails |
canActivate |
After matching, before activation | "May the user enter this route?" | Navigation is cancelled (or redirected) |
canActivateChild |
After matching, for each child route being activated | "May the user enter any child of this route?" | Navigation is cancelled (or redirected) |
canDeactivate |
Before leaving the current route | "May the user leave this component?" | The user stays on the current route |
All four can return true/false, a UrlTree or RedirectCommand to redirect, or a Promise or Observable of those. The router uses the first value an Observable emits and then unsubscribes. When several guards are listed in one array, the router runs them in order.
A typical functional guard:
export const adminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAdmin() ? true : router.createUrlTree(['/forbidden']);
};
Guards run in an injection context, so inject() works inside them.
Cause 1: The guard is on the wrong route #
Guard on a parent, navigation between its children #
The router only runs canActivate on routes that are being newly activated, or that changed according to runGuardsAndResolvers (cause 3). When you navigate from /admin/users to /admin/reports, the admin parent route stays active, so its canActivate doesn't run again:
{
path: 'admin',
canActivate: [adminGuard], // runs when entering /admin/..., not between children
children: [
{ path: 'users', component: Users },
{ path: 'reports', component: Reports },
],
}
If permission depends on the child (reports need an extra role) or must be re-checked on every child navigation, use canActivateChild on the parent, which runs for each child being activated:
{
path: 'admin',
canActivate: [adminGuard],
canActivateChild: [permissionGuard], // runs for /admin/users, /admin/reports, ...
children: [ /* ... */ ],
}
Guard on a redirectTo route #
Redirects are applied during matching, before activation, so a route with redirectTo is never activated and a canActivate on it would never run. Angular's route configuration check rejects that combination for exactly this reason. Put the guard on the route the redirect points to.
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, // here
A different route matched first #
Routes are matched in order, and the first match wins. If an earlier route (a wildcard placed too high, or a path: '' without pathMatch: 'full' that swallows everything) matches the URL, your guarded route is never reached. Check route order before checking the guard.
Cause 2: canActivate on a lazy route doesn't stop the download #
{
path: 'admin',
canActivate: [adminGuard],
loadChildren: () => import('./admin/admin.routes'),
}
This protects the admin pages, but the router loads admin.routes while it matches the URL, and canActivate runs after matching. So a non-admin user who navigates to /admin still downloads the admin chunk; they just can't see it.
canMatch runs during matching, before loadChildren or loadComponent is called. If it rejects, the route is skipped and the code isn't loaded:
{
path: 'admin',
canMatch: [adminMatchGuard],
loadChildren: () => import('./admin/admin.routes'),
},
{ path: 'admin', component: Forbidden }, // fallback when canMatch rejects
Because a rejected canMatch falls through to the next matching route, you can also use it to serve different components for the same path based on a role or feature flag. Remember the flip side: if canMatch rejects and nothing else matches, the user ends up on your wildcard or not-found route rather than seeing a "forbidden" page, unless you return a UrlTree to redirect.
Cause 3: Same route, new parameters, no re-run #
When you navigate to the same route with different parameters, Angular reuses the component and decides whether to re-run guards and resolvers based on the route's runGuardsAndResolvers option. The default is 'paramsChange': re-run when the path or path parameters (including matrix parameters) change, but not when only query parameters change.
So with the default, going from /orders/1 to /orders/2 re-runs the guard, but going from /orders/1?tab=summary to /orders/1?tab=billing does not. If your guard checks something in the query string, choose a mode that includes it:
runGuardsAndResolvers |
Re-runs when |
|---|---|
'paramsChange' (default) |
Path or path/matrix params change |
'pathParamsChange' |
Path params change (ignores matrix and query params) |
'pathParamsOrQueryParamsChange' |
Path params or query params change (ignores matrix params) |
'paramsOrQueryParamsChange' |
Path, matrix or query params change |
'always' |
Every navigation to the route |
(from, to) => boolean |
Your function returns true |
{
path: 'orders/:id',
component: OrderDetail,
canActivate: [orderAccessGuard],
runGuardsAndResolvers: 'paramsOrQueryParamsChange',
}
A related trap: navigating to the exact same URL (clicking the current link again) is ignored by the router by default. If you rely on a guard or resolver re-running in that case, set onSameUrlNavigation: 'reload' (via withRouterConfig()) together with runGuardsAndResolvers: 'always'.
Cause 4: An async guard that never settles #
A guard that returns an Observable waits for its first emission. If the Observable never emits, the navigation hangs: the URL doesn't change, nothing errors, and the next navigation simply supersedes it.
// ✗ hangs while user$ is a Subject that hasn't emitted yet
export const authGuard: CanActivateFn = () =>
inject(AuthService).user$.pipe(map(user => !!user));
Make sure the source emits for every case, including "still loading" and "logged out":
// ✓ wait for auth to finish initialising, then decide once
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.state$.pipe(
filter(s => s.status !== 'loading'),
take(1),
map(s => s.user ? true : router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })),
);
};
If your auth state is a signal, toObservable() or a small Promise that resolves when initialisation finishes works the same way. Also guard against errors: an Observable that errors cancels the navigation with a NavigationError.
Cause 5: Redirects that loop or are ignored #
Prefer returning a UrlTree or RedirectCommand over calling router.navigate() inside a guard and returning false. A returned redirect replaces the current navigation cleanly; a navigate() call starts a second navigation while the first is still being cancelled.
export const authGuard: CanActivateFn = () => {
const router = inject(Router);
if (inject(AuthService).isLoggedIn()) return true;
return new RedirectCommand(router.parseUrl('/login'), { skipLocationChange: true });
};
RedirectCommand accepts navigation options such as skipLocationChange or replaceUrl, which a plain UrlTree can't carry. Watch for loops: if /login is itself behind authGuard (for example, the guard sits on a parent route that also contains login), every redirect triggers the guard again.
canDeactivate doesn't cover leaving the app #
canDeactivate runs for in-app navigations: router links, back and forward within the app, router.navigate(). It does not run when the user closes the tab, reloads, or types a different site into the address bar. For unsaved-changes protection, pair it with a beforeunload listener:
export const unsavedChangesGuard: CanDeactivateFn<EditInvoice> = component =>
component.hasUnsavedChanges() ? confirm('Discard unsaved changes?') : true;
// in EditInvoice
host: { '(window:beforeunload)': 'onBeforeUnload($event)' },
onBeforeUnload(e: BeforeUnloadEvent) {
if (this.hasUnsavedChanges()) e.preventDefault();
}
Debugging guards #
- Turn on tracing:
provideRouter(routes, withDebugTracing())logs every router event to the console. Look forGuardsCheckStartandGuardsCheckEnd(withshouldActivate) for your navigation. NoGuardsCheckStartfor the route means matching didn't reach it, orrunGuardsAndResolversdecided not to re-run. - Check
NavigationCancel: itscodetells you why the navigation stopped, for exampleGuardRejectedorRedirect. - Log inside the guard with the route path and URL, so you know which config entry it's attached to.
- Read the route tree top-down: order,
pathMatch, wildcards, and where the guard sits relative to the route you navigated to.
Lesson 5.4 walks through the full event sequence.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
Guard runs on /admin but not between /admin/* pages |
canActivate on the parent; parent stays active |
canActivateChild, or guard each child |
| Unauthorised users still download the lazy chunk | canActivate runs after loadChildren |
canMatch on the lazy route |
Guard doesn't re-run when ?tab= changes |
Default runGuardsAndResolvers: 'paramsChange' ignores query params |
'paramsOrQueryParamsChange' or 'pathParamsOrQueryParamsChange' |
| Clicking a link to the current page does nothing | Same-URL navigation ignored by default | onSameUrlNavigation: 'reload' + runGuardsAndResolvers: 'always' |
| Navigation hangs with no error | Guard's Observable never emits | Ensure it emits for every state; filter + take(1) |
| Infinite redirect | Redirect target is protected by the same guard | Move the guard so the target isn't covered |
What's next #
Lesson 10.8 closes Module 10 with the hardest category of runtime errors: hydration mismatches. You'll learn the NG0500-range errors, the usual culprits from random IDs to invalid HTML, and how to use ngSkipHydration and the Angular DevTools hydration overlay to track them down.
Try it yourself #
Add withDebugTracing() to your router setup in development, navigate between two child routes of a guarded parent, and then change only a query parameter. Note which navigations log GuardsCheckStart for your guard. If any should have re-checked permissions and didn't, adjust the guard type or runGuardsAndResolvers.
search_documentationTwo separate things. The download: the router loads loadChildren while matching the URL, and canActivate only runs after matching, so the chunk is fetched before your guard decides. Change it to canMatch: [adminGuard] (the function signature becomes CanMatchFn); a rejected canMatch skips the route before its code loads. The not-found page: that’s the flip side of canMatch. When it returns false, the router tries the next matching route, and only your ** wildcard matches. Either return inject(Router).createUrlTree(['/forbidden']) from the guard instead of false, or add a second { path: 'admin', component: Forbidden } entry right after the lazy one as a fallback. Keep in mind that hiding the chunk is a nice-to-have; the real protection is your API checking the role on every admin request.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


