Angular AuthGuard: Functional Route Guards with Examples

Last updated: October 21, 2026 · Tested with Angular 22.2.3 (CLI and router), RxJS 7.8.2, TypeScript 6.0.3, Vitest 5.0.5, Node 24.21, headless Edge 155, Windows 11

Short answer: in current Angular an AuthGuard is a plain function of type CanActivateFn. It gets services with inject(), returns true to let the user in, and returns a UrlTree to send them to the login page. You add it to a route with canActivate: [authGuard]:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isLoggedIn()) {
    return true;
  }
  // Return a UrlTree instead of calling router.navigate() and returning false
  return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};

I wrote the first version of this post in 2023 for Angular 14, with a class that implemented CanActivate and called router.navigate(). For this update I built a fresh Angular 22 app, ran every guard below in a real browser and recorded what the router did. The class version still works, but it loses the page the user wanted, and its route syntax is now marked deprecated. The tests also turned up three traps that tutorials rarely mention: an NG0203 crash that only hits logged-out users, a guard that freezes navigation without any error, and a canActivate guard that downloads your admin code to users it rejects.

What ng generate guard Creates Now

The CLI still has the command, but the output changed. It now creates a functional guard by default, and the file name uses a dash (auth-guard.ts), not auth.guard.ts as in older tutorials:

$ ng generate guard gen/auth --defaults
CREATE src/app/gen/auth-guard.spec.ts (476 bytes)
CREATE src/app/gen/auth-guard.ts (133 bytes)

import { CanActivateFn } from '@angular/router';

export const authGuard: CanActivateFn = (route, state) => {
  return true;
};

$ ng generate guard gen/old --functional=false --defaults
CREATE src/app/gen/old-guard.spec.ts (340 bytes)
CREATE src/app/gen/old-guard.ts (397 bytes)
import { Injectable } from '@angular/core';
import {
  ActivatedRouteSnapshot,
  CanActivate,
  GuardResult,
  MaybeAsync,
  RouterStateSnapshot,
} from '@angular/router';

@Injectable({
  providedIn: 'root',
})
export class OldGuard implements CanActivate {
  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): MaybeAsync<GuardResult> {
    return true;
  }
}

Use the functional version for new code. The rest of this post builds a working one step by step.

Step 1: An AuthService the Guard Can Ask

The guard only needs a yes or no answer. In a real app this service holds your token or session cookie state. The demo keeps the user in sessionStorage so a refresh keeps you logged in, the way a cookie or token would:

import { Injectable, signal } from '@angular/core';
import { Observable, delay, of } from 'rxjs';

export interface User { name: string; roles: string[]; }

@Injectable({ providedIn: 'root' })
export class AuthService {
  // sessionStorage so a refresh keeps the demo user, like a cookie or token would
  private readonly user = signal<User | null>(JSON.parse(sessionStorage.getItem('user') ?? 'null'));
  readonly currentUser = this.user.asReadonly();

  isLoggedIn(): boolean { return this.user() !== null; }
  hasRole(role: string): boolean { return this.user()?.roles.includes(role) ?? false; }

  login(name: string, roles: string[] = []): void {
    const user = { name, roles };
    sessionStorage.setItem('user', JSON.stringify(user));
    this.user.set(user);
  }

  logout(): void {
    sessionStorage.removeItem('user');
    this.user.set(null);
  }

  // Stands in for GET /api/me: the answer arrives later, as it would over HTTP
  checkSession(): Observable<boolean> {
    return of(this.isLoggedIn()).pipe(delay(300));
  }
}

Step 2: The Functional authGuard

The guard is the code from the top of this post. Two details matter:

  • Call inject() at the top of the function, before any await, callback or RxJS operator. The router runs your guard inside an injection context, but only while the function body is running synchronously.
  • Return the redirect, don’t perform it. router.createUrlTree() builds the URL and the router does the redirect itself. Putting the requested URL in returnUrl lets the login page send the user back.

Step 3: Protect the Routes

With standalone components (the default since Angular 17) the routes live in app.routes.ts. No AppRoutingModule is needed:

import { Routes } from '@angular/router';
import { authGuard } from './auth.guard';
import { Dashboard, Home, Login, NotFound } from './pages';

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'login', component: Login },
  { path: 'dashboard', component: Dashboard, canActivate: [authGuard] },
  { path: '**', component: NotFound },
];

The login page reads returnUrl and goes back there after a successful login:

export class Login {
  private readonly auth = inject(AuthService);
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);

  login(): void {
    this.auth.login('ana');
    const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/dashboard';
    this.router.navigateByUrl(returnUrl);
  }
}

Here is what happened in the browser when a logged-out user opened /dashboard directly, logged router events included. After clicking Log in the app went back to /dashboard:

URL /login?returnUrl=%2Fdashboard  page "Login"
  NavigationStart /dashboard
  NavigationCancel /dashboard code=0 reason="NavigationCancelingError: Redirecting to "/login?returnUrl=%2Fdashboard""
  NavigationStart /login?returnUrl=%2Fdashboard
  NavigationEnd /login?returnUrl=%2Fdashboard

The NavigationCancel with code=0 is normal. Code 0 is NavigationCancellationCode.Redirect: the router cancelled /dashboard because the guard asked for a redirect.

Why Return a UrlTree Instead of router.navigate()

The 2023 version of this post redirected like this:

    if (!this.authService.isLoggedIn()) {
      this.router.navigate(['/login']);
      return false;
    }
    return true;

It does land on the login page, but the router events show what really happens. The guard starts a second navigation while the first one is still running, and the first one is cancelled as superseded (code 1), not as a redirect:

URL /login  page "Login"
  NavigationStart /legacy
  NavigationCancel /legacy code=1 reason="Navigation ID 2 is not equal to the current navigation id 3"
  NavigationStart /login
  NavigationEnd /login

Compare it with the UrlTree guard on the same click:

URL /login?returnUrl=%2Fdashboard  page "Login"
  NavigationStart /dashboard
  NavigationCancel /dashboard code=0 reason="NavigationCancelingError: Redirecting to "/login?returnUrl=%2Fdashboard""
  NavigationStart /login?returnUrl=%2Fdashboard
  NavigationEnd /login?returnUrl=%2Fdashboard

In practice the difference is:

  • The old code lost the requested page. The user always ends up on the dashboard after logging in. That’s fixable, but only by passing state.url yourself.
  • With several guards on one route, a guard that navigates and returns false can race with the others. When guards return UrlTrees, the router decides which redirect wins.
  • The UrlTree version returns its decision as a value, so the unit test below just checks the returned URL. No router spy needed.

Since Angular 18 a guard can also return new RedirectCommand(urlTree, { skipLocationChange: true }) when you need navigation options on the redirect.

Is the Class-Based AuthGuard Deprecated?

Partly. I compiled the unchanged 2023 class on Angular 22.2.3. It builds with no warnings and still redirects. The CanActivate interface itself is not deprecated. What is deprecated is putting the class token directly in the route. In @angular/router 22.2.3 the route type is canActivate?: Array<CanActivateFn | DeprecatedGuard>, and DeprecatedGuard carries a @deprecated tag that recommends plain functions. Nothing warns you about it: ng build stays silent, so the only place you see it is the router’s type definitions.

If you have many class guards, you don’t need to rewrite them in one go. mapToCanActivate wraps them as functions:

  { path: 'legacy-mapped', component: Dashboard, canActivate: mapToCanActivate([AuthGuard]) },

The same helpers exist for the other guard types (mapToCanActivateChild, mapToCanDeactivate, mapToCanMatch, mapToResolve).

Async Guards: Checking the Session With an Observable

When the app has to ask the server whether the session is still valid (after a refresh, for example), the guard returns an Observable or a Promise. The router waits for it:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { map } from 'rxjs';
import { AuthService } from './auth.service';

export const sessionGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router); // inject() here, not inside map()

  return auth.checkSession().pipe(
    map(ok => ok ? true : router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }))
  );
};
URL /login?returnUrl=%2Fsession  page "Login"
  NavigationStart /session
  NavigationCancel /session code=0 reason="NavigationCancelingError: Redirecting to "/login?returnUrl=%2Fsession""
  NavigationStart /login?returnUrl=%2Fsession
  NavigationEnd /login?returnUrl=%2Fsession

While the guard waits, the URL stays on the old page (it was still / 200 ms after the click). If the check is slow, show a loading indicator from the router events. Two bugs are easy to write here.

Trap 1: NG0203 from inject() inside map()

This version looks almost the same. The only change is that inject(Router) moved into the callback:

export const lateInjectGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  return auth.checkSession().pipe(
    map(ok => ok ? true : inject(Router).createUrlTree(['/login']))
  );
};

For a logged-in user it works, because the callback never reaches inject(). For a logged-out user the navigation fails:

URL /  page "Home"
  NavigationStart /late-inject
  NavigationError /late-inject error="NG0203: The `Router` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203"
  navigateByUrl result: rejected: NG0203: The `Router` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203

In a production build the message is only the code (rejected: NG0203), and the user stays on the old page with no redirect. That combination (fine in your own testing while logged in, broken only for logged-out users, a bare error code in production) makes this bug easy to ship. The fix is to call every inject() at the top of the guard, as in sessionGuard above. The same rule applies after an await in an async guard.

Trap 2: An Observable That Never Emits Freezes Navigation

The router waits for the first value. If the observable never emits (a subject nobody calls next() on, or a state stream that starts empty), the navigation stays pending forever. No error, no console message:

URL /  page "Home"
  NavigationStart /never
  navigateByUrl settled: no (still pending)

An observable that emits and stays open is fine. The router takes the first value. A BehaviorSubject that never completes navigated normally:

URL /open-stream  page "Dashboard"
  NavigationStart /open-stream
  NavigationEnd /open-stream

If your auth state can start out “unknown”, filter out that state and add a timeout() so a stuck check ends in a redirect, not a frozen app.

Role-Based Guard Using Route Data

One guard can serve many routes if it reads the required role from data:

import { inject } from '@angular/core';
import { CanActivateFn, CanMatchFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

// Reads the required role from the route: data: { role: 'admin' }
export const roleGuard: CanActivateFn = (route) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  const role = route.data['role'] as string;

  return auth.hasRole(role) ? true : router.createUrlTree(['/forbidden']);
};

// canMatch runs before a lazy route is loaded, so non-admins never download the code
export const adminMatch: CanMatchFn = () => inject(AuthService).hasRole('admin');
  {
    path: 'reports',
    canActivate: [authGuard, roleGuard],
    data: { role: 'admin' },
    loadComponent: () => import('./reports/reports').then(m => m.Reports),
  },

Guards in the array run in order, and every one must allow the navigation. A logged-in user without the admin role:

URL /forbidden  page "Forbidden"
  NavigationStart /reports
  NavigationCancel /reports code=0 reason="NavigationCancelingError: Redirecting to "/forbidden""
  NavigationStart /forbidden
  NavigationEnd /forbidden

To protect a whole section, put canActivateChild on the parent route. It runs for every child, and the guard receives the child’s URL in state.url:

  {
    path: 'account',
    canActivateChild: [authGuard],
    children: [
      { path: 'profile', component: Dashboard },
      { path: 'billing', component: Dashboard },
    ],
  },
URL /login?returnUrl=%2Faccount%2Fbilling  page "Login"
  NavigationStart /account/billing
  NavigationCancel /account/billing code=0 reason="NavigationCancelingError: Redirecting to "/login?returnUrl=%2Faccount%2Fbilling""
  NavigationStart /login?returnUrl=%2Faccount%2Fbilling
  NavigationEnd /login?returnUrl=%2Faccount%2Fbilling

canMatch vs canActivate for Lazy-Loaded Routes

canLoad is deprecated in Angular 22 (@deprecated Use canMatch instead). I wanted to know what each guard costs in downloads, so I recorded which lazy JavaScript chunks the browser requested for three admin-only routes: /reports (canActivate + loadComponent), /admin (canMatch + loadComponent) and /tools (canActivate + loadChildren):

roles=[]        /reports -> /forbidden "Forbidden"   lazy scripts: none
roles=[]        /admin   -> /admin     "Not found"   lazy scripts: none
roles=[]        /tools   -> /forbidden "Forbidden"   lazy scripts: /chunk-H3C6KTTE.js
roles=["admin"] /reports -> /reports   "Reports"   lazy scripts: /chunk-UXYO7KEW.js
roles=["admin"] /admin   -> /admin     "Admin"   lazy scripts: /chunk-TBHOM62C.js
roles=["admin"] /tools   -> /tools     "Tools"   lazy scripts: /chunk-H3C6KTTE.js

What this shows:

  • canActivate with loadChildren downloads the chunk even when it rejects the user. The router needs the child routes to finish matching the URL, so it loads them before the guard runs. The non-admin user got redirected, but their browser already had the admin code.
  • canMatch never downloads anything for rejected users. The route doesn’t match, so the router moves on to the next route (here the ** Not found page, with the URL kept).
  • With loadComponent, canActivate didn’t download the component chunk for the rejected user.

Use canMatch on lazy sections that some users should never load, and add a fallback route if you want a “Forbidden” page instead of “Not found”. Remember that hiding code is not security: the chunk is still a public file on your server.

canDeactivate: Warn About Unsaved Changes

The opposite direction: stop the user from leaving a page. The guard receives the component instance:

import { CanDeactivateFn } from '@angular/router';

export interface HasUnsavedChanges {
  hasUnsavedChanges(): boolean;
}

export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
  component.hasUnsavedChanges()
    ? confirm('You have unsaved changes. Leave this page?')
    : true;
  { path: 'editor', component: Editor, canDeactivate: [unsavedChangesGuard] },

I typed into the editor and tried to navigate away twice, clicking Cancel and then OK:

URL /editor  page "Editor"
  NavigationStart /
  NavigationCancel / code=3
  [dialog] confirm: "You have unsaved changes. Leave this page?" -> Cancel
URL /  page "Home"
  NavigationStart /
  NavigationEnd /
  [dialog] confirm: "You have unsaved changes. Leave this page?" -> OK

Code 3 is GuardRejected. Note that canDeactivate only covers navigation inside Angular. Closing the tab or typing another site’s address needs a beforeunload handler as well.

Unit Testing a Functional Guard

A functional guard calls inject(), so the test runs it inside TestBed.runInInjectionContext(). New Angular 22 projects run tests with Vitest:

import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree, provideRouter } from '@angular/router';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';

describe('authGuard', () => {
  const route = {} as ActivatedRouteSnapshot;
  const state = { url: '/dashboard' } as RouterStateSnapshot;

  beforeEach(() => {
    sessionStorage.clear();
    TestBed.configureTestingModule({ providers: [provideRouter([])] });
  });

  it('allows a logged-in user', () => {
    TestBed.inject(AuthService).login('ana');
    const result = TestBed.runInInjectionContext(() => authGuard(route, state));
    expect(result).toBe(true);
  });

  it('redirects to /login with returnUrl', () => {
    const result = TestBed.runInInjectionContext(() => authGuard(route, state));
    expect(result).toBeInstanceOf(UrlTree);
    expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/login?returnUrl=%2Fdashboard');
  });

  it('throws NG0203 when called outside an injection context', () => {
    expect(() => authGuard(route, state)).toThrow(/NG0203/);
  });
});
 RUN  v5.0.5 C:/Users/rajni/AppData/Local/Temp/ngtmp/guards
 Test Files  1 passed (1)
      Tests  3 passed (3)
   Duration  2.22s (environment 73%, setup 16%, transform 6%, import 3%, tests 2%, worker 1%)

The third test shows the error you get when the guard runs outside an injection context, the same NG0203 as in Trap 1.

A Guard Is Not Security

Everything in this post runs in the user’s browser. Anyone can open the dev tools, set sessionStorage or edit the JavaScript, and the guard lets them through. Guards are for user experience: they keep people away from pages that would fail anyway. The real check has to happen on the server, on every API call. If your backend is ASP.NET Core, see JWT authentication in ASP.NET Core for the token side and authentication vs authorization in ASP.NET Core for role and policy checks.

Common AuthGuard Errors and Fixes

SymptomCauseFix
NG0203: The `Router` token injection failed. `inject()` function must be called from an injection contextinject() inside map, subscribe, setTimeout or after awaitCall all inject()s on the first lines of the guard
Navigation does nothing, no errorGuard observable never emitsMake sure it emits; add filter + timeout()
After login the user lands on the home page, not the page they asked forrouter.navigate() + return false without returnUrlReturn createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })
Old tutorials say class guards are deprecatedClass tokens in routes are DeprecatedGuardFunctional guard, or mapToCanActivate([AuthGuard])
canLoad marked deprecatedReplaced by canMatchUse canMatch
Admin chunk downloaded by normal userscanActivate on a loadChildren routecanMatch
Logged out after pressing F5Auth state kept only in memoryRestore it from a cookie or storage, or use an async guard that asks the server
404 from the server on refresh of a guarded pageServer has no SPA fallback (not a guard problem)See Angular 404 on page refresh

How I Tested This

A new app from ng new (Angular CLI 22.2.3, standalone, no SSR) with all the guards above. ng build and ng test ran clean. The browser results come from a script that drives headless Edge 155 over the Chrome DevTools Protocol against ng serve. It logs router events, dialogs and every script the page requests. The production NG0203 check used the ng build output. The official reference is the Angular route guards guide, and the NG0203 error page explains injection contexts.

More Angular and ASP.NET Core posts: Angular CRUD with ASP.NET Core Web API · Angular date pipe formats · all ASP.NET Core tutorials

Similar Posts

3 Comments

  1. Angular Router guards provide an important mechanism for controlling access to routes in an application. The article explains how guards such as canActivate, canActivateChild, canDeactivate, and canLoad can be used to prevent unauthorized users from reaching protected pages. The canActivate example demonstrates how authentication status can be checked before allowing navigation.

    The step-by-step use of the Angular CLI command to generate an authentication guard provides a practical starting point for developers learning route protection through Angular Online Training. The example also shows how AuthService and Angular's Router can work together inside the guard.

  2. The AuthGuard implementation demonstrates the use of ActivatedRouteSnapshot, RouterStateSnapshot, Observable, Promise, and UrlTree as possible return types from canActivate. Understanding these TypeScript types can help developers work more confidently with Angular routing and authentication logic through TypeScript Online Training.

  3. Route protection is also an important feature when developing applications that contain user accounts, dashboards, administrative pages, or other restricted functionality. The concepts discussed in the article can therefore be applied to practical Angular Projects that require controlled navigation and authentication-aware routing.

Leave a Reply

Your email address will not be published. Required fields are marked *