Angular 80 🅰️ Authentication and Route Protection
In the previous chapters, you learned the router’s event stream, the navigation lifecycle, and the guard types that gate navigation. This chapter puts those pieces together for the specific problem that almost every production application faces: authentication and authorization. How do you know who the user is, what they are allowed to see, and how do you enforce those rules without relying on a server round-trip for every page change?
Angular’s answer is the route guard. A guard is a function that runs during navigation and returns a decision: allow, block, or redirect . The Angular documentation is explicit about the limits of this mechanism: “Never rely on client-side guards as the sole source of access control. All JavaScript that runs in a web browser can be modified by the user running the browser. Always enforce user authorization server-side, in addition to any client-side guards” . The client guard is a user-experience tool — it prevents the application from attempting to render protected content and redirects the user gracefully. It is not a security boundary.
Key point: Angular’s route guards are functional since Angular 15. A guard is a function typed as CanActivateFn, CanActivateChildFn, CanDeactivateFn, or CanMatchFn that uses inject() to access services and returns true, false, a UrlTree, or an observable/promise of these . The guard is declared in the route configuration as an array, and guards in the array execute in order.
Why route protection matters
Without route guards, every protected page must handle its own authentication check. The component loads, injects the auth service, checks whether the user is logged in, and conditionally shows content or redirects. This is duplicated logic, and it runs after the router has already activated the route. The user sees a flash of the protected page before the redirect.
The eager activation problem. The router activates a route before the component’s ngOnInit runs. Without a guard, the route is activated, the component is instantiated, and only then does the component realize the user is not authenticated. The guard moves the check to the navigation phase, before activation. The route never activates if the guard blocks it .
The redirect problem. When an unauthenticated user tries to visit /admin, the desired behavior is not just to block the navigation but to send them to /login and, after login, return them to /admin. Guards return a UrlTree to trigger this redirect. The current URL is captured as a returnUrl query parameter, and the login component navigates back after success .
The role problem. Authentication is binary — you are logged in or not. Authorization is hierarchical — an admin can see /admin, a moderator can see /moderation, a regular user can see neither. Role guards read the required roles from the route’s data property and check them against the user’s roles. A single guard can handle all role checks if the required roles are declared in the route configuration .
The trade-off. Client-side guards add a layer of code that must be maintained and tested. For applications where every route is public, the guards are unnecessary overhead. For applications with a login, the guards are essential. The decision is not whether to use guards, but which guard types and how to keep them simple.
a. Functional Guards and the AuthService
The modern guard is a function, not a class. It is declared as a const of the appropriate function type and uses inject() to access dependencies .
// auth.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
The isAuthenticated() method comes from an AuthService that manages the user’s session state. A minimal implementation uses a BehaviorSubject or a signal to track whether a user is logged in .
// auth.service.ts
import { Injectable, signal, computed } from '@angular/core';
export interface User {
id: string;
username: string;
roles: string[];
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private _user = signal<User | null>(null);
private _token = signal<string | null>(null);
readonly user = this._user.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null);
async login(credentials: { username: string; password: string }): Promise<boolean> {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
const data = await response.json();
this._token.set(data.token);
this._user.set(data.user);
return true;
}
logout(): void {
this._user.set(null);
this._token.set(null);
}
}
The guard returns true to allow navigation or a UrlTree to redirect. The Angular documentation recommends returning a UrlTree rather than calling router.navigate() inside the guard: “If you need to redirect the user, return a URLTree or RedirectCommand. Do not return false and then programmatically navigate the user” . The UrlTree approach lets the router cancel the current navigation and start a new one in a single, coherent step .
b. Role-Based Guards and Route Data
A single role guard can serve every route if the required roles are declared in the route’s data property. The guard reads route.data['roles'] and compares it against the user’s roles .
// role.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from './auth.service';
export const roleGuard: CanActivateFn = (route) => {
const authService = inject(AuthService);
const router = inject(Router);
const requiredRoles = route.data['roles'] as string[];
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: router.url },
});
}
if (requiredRoles && !authService.hasAnyRole(requiredRoles)) {
return router.createUrlTree(['/unauthorized']);
}
return true;
};
The route configuration declares which roles are allowed:
export const routes: Routes = [
{
path: 'admin',
loadComponent: () => import('./admin.component').then(m => m.AdminComponent),
canActivate: [authGuard, roleGuard],
data: { roles: ['admin'] },
},
{
path: 'moderation',
loadComponent: () => import('./moderation.component').then(m => m.ModerationComponent),
canActivate: [authGuard, roleGuard],
data: { roles: ['admin', 'moderator'] },
},
];
The hasAnyRole method on the service checks whether the user has at least one of the required roles. This pattern avoids writing a separate guard for every role. The route data declares the requirement, and the guard enforces it .
For nested routes, CanActivateChild runs the guard for every child route without repeating the guard on each one .
export const adminGuard: CanActivateChildFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.hasRole('admin')) {
return true;
}
return router.createUrlTree(['/unauthorized']);
};
// Route:
{
path: 'admin',
canActivateChild: [adminGuard],
children: [
{ path: 'users', component: AdminUsersComponent },
{ path: 'settings', component: AdminSettingsComponent },
],
}
c. The Return URL Pattern and Async Guards
The return URL pattern preserves the user’s intended destination through a login redirect. The guard captures state.url and passes it as a query parameter. The login component reads the parameter after successful authentication and navigates to it .
// In the guard
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
// In the login component
async login() {
const success = await this.authService.login(this.credentials);
if (success) {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
}
}
The || '/' fallback handles the case where the user navigated directly to the login page without a return URL. Without it, navigateByUrl(undefined) would throw .
Guards can return observables or promises when the authentication check is asynchronous. The router waits for the observable to emit and complete before continuing .
export const asyncAuthGuard: CanActivateFn = async (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
try {
const isValid = await authService.validateToken();
if (isValid) {
return true;
}
} catch {
// fall through to redirect
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
When returning an observable, the take(1) operator ensures the stream completes after the first value. The router does not finalize the navigation until the observable completes .
Complete Example Session
This session builds a complete authentication flow: an auth service with signals, a functional guard, a role guard, a login component that handles the return URL, and the route configuration.
// ============================================
// PART 1: THE AUTH SERVICE
// ============================================
import { Injectable, signal, computed } from '@angular/core';
export interface User {
id: string;
username: string;
roles: string[];
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private _user = signal<User | null>(null);
private _token = signal<string | null>(null);
readonly user = this._user.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null);
hasRole(role: string): boolean {
return this._user()?.roles.includes(role) ?? false;
}
hasAnyRole(roles: string[]): boolean {
const user = this._user();
return user ? roles.some(r => user.roles.includes(r)) : false;
}
async login(username: string, password: string): Promise<boolean> {
// Replace with actual HTTP call
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
const data = await response.json();
this._token.set(data.token);
this._user.set(data.user);
return true;
}
logout(): void {
this._user.set(null);
this._token.set(null);
}
}
// ============================================
// PART 2: THE AUTH GUARD
// ============================================
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
// ============================================
// PART 3: THE ROLE GUARD
// ============================================
export const roleGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
const requiredRoles = route.data['roles'] as string[];
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
}
if (requiredRoles && !authService.hasAnyRole(requiredRoles)) {
return router.createUrlTree(['/unauthorized']);
}
return true;
};
// ============================================
// PART 4: THE LOGIN COMPONENT
// ============================================
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from './auth.service';
@Component({
selector: 'app-login',
imports: [FormsModule],
template: `
<form (ngSubmit)="login()">
<input [(ngModel)]="username" name="username" placeholder="Username" />
<input [(ngModel)]="password" name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
`,
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
username = '';
password = '';
async login() {
const success = await this.authService.login(this.username, this.password);
if (success) {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
}
}
}
// ============================================
// PART 5: THE ROUTE CONFIGURATION
// ============================================
import { Routes } from '@angular/router';
import { authGuard, roleGuard } from './auth.guard';
import { LoginComponent } from './login.component';
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent),
canActivate: [authGuard],
},
{
path: 'admin',
loadComponent: () => import('./admin.component').then(m => m.AdminComponent),
canActivate: [authGuard, roleGuard],
data: { roles: ['admin'] },
},
{
path: 'moderation',
loadComponent: () => import('./moderation.component').then(m => m.ModerationComponent),
canActivate: [authGuard, roleGuard],
data: { roles: ['admin', 'moderator'] },
},
{
path: 'unauthorized',
loadComponent: () => import('./unauthorized.component').then(m => m.UnauthorizedComponent),
},
{ path: '**', redirectTo: '' },
];
// ============================================
// PART 6: THE ASYNC GUARD
// ============================================
export const asyncAuthGuard: CanActivateFn = async (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
try {
const isValid = await authService.validateToken();
if (isValid) {
return true;
}
} catch {
// token validation failed
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
// ============================================
// PART 7: THE CANMATCH GUARD FOR FEATURE FLAGS
// ============================================
import { CanMatchFn } from '@angular/router';
import { FeatureService } from './feature.service';
export const betaFeatureGuard: CanMatchFn = (route, segments) => {
const featureService = inject(FeatureService);
return featureService.isEnabled('betaDashboard');
};
// Route:
// {
// path: 'dashboard',
// loadComponent: () => import('./beta-dashboard.component').then(m => m.BetaDashboardComponent),
// canMatch: [betaFeatureGuard],
// },
// {
// path: 'dashboard',
// loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent),
// }
// ============================================
// PART 8: THE LOGOUT FLOW
// ============================================
@Component({
selector: 'app-header',
template: `
@if (authService.isAuthenticated()) {
<span>{{ authService.user()?.username }}</span>
<button (click)="logout()">Logout</button>
}
`,
})
export class HeaderComponent {
authService = inject(AuthService);
private router = inject(Router);
logout() {
this.authService.logout();
this.router.navigate(['/login']);
}
}
// ============================================
// PART 9: THE TOKEN INTERCEPTOR
// ============================================
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.token();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req);
};
// ============================================
// PART 10: THE GUARD TEST WITH HARNESS
// ============================================
import { TestBed } from '@angular/core/testing';
import { RouterTestingHarness } from '@angular/router/testing';
import { provideRouter } from '@angular/router';
import { authGuard } from './auth.guard';
describe('authGuard', () => {
it('should redirect unauthenticated users to login', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: 'admin', component: AdminComponent, canActivate: [authGuard] },
{ path: 'login', component: LoginComponent },
]),
],
});
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/admin');
expect(TestBed.inject(Router).url).toBe('/login?returnUrl=%2Fadmin');
});
});
The ten parts cover the auth service with signals, the functional auth guard, the role guard with route data, the login component with return URL handling, the route configuration, the async guard, the CanMatch feature flag guard, the logout flow, the token interceptor, and the guard test with RouterTestingHarness.
Quick Reference
The Guard Types
| Guard | Question | Use Case |
|---|---|---|
CanActivate | Can this route activate? | Authentication |
CanActivateChild | Can child routes activate? | Section-wide protection |
CanDeactivate | Can this route deactivate? | Unsaved changes |
CanMatch | Should this route match? | Feature flags, A/B |
The Guard Return Types
| Return | Effect |
|---|---|
true | Navigation continues |
false | Navigation blocked |
UrlTree | Redirect to the tree’s URL |
Observable<T> | Router awaits first emission |
Promise<T> | Router awaits resolution |
The Common Guard Patterns
| Pattern | Implementation |
|---|---|
| Auth check | authService.isAuthenticated() |
| Role check | route.data['roles'] + hasAnyRole() |
| Return URL | state.url as query param |
| Async validation | async function with await |
Best Practices
✅ Do This:
// Return a UrlTree for redirects
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }); // ✅
// Use route data for role requirements
data: { roles: ['admin'] } // ✅
// Use CanActivateChild for nested route protection
canActivateChild: [adminGuard] // ✅
// Validate server-side in addition to guards
// The server must check authorization for every API call. // ✅
// Use signals for reactive auth state
readonly isAuthenticated = computed(() => this._user() !== null); // ✅
❌ Don’t Do This:
// Don't rely solely on client-side guards
// JavaScript can be modified by the user. // ❌
// Don't return false and then call router.navigate
this.router.navigate(['/login']); return false; // ❌
// Don't use class-based guards in new code
@Injectable() export class AuthGuard implements CanActivate {} // ❌ deprecated
// Don't forget the returnUrl fallback
this.router.navigateByUrl(this.returnUrl); // undefined crashes // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Redirect loop | Guard redirects to a protected route | Ensure login route is not guarded |
returnUrl undefined | Direct navigation to login | Use || '/' fallback |
| Guard not running | Wrong guard type for route position | Match CanActivate vs CanActivateChild |
| Role check fails | route.data['roles'] not declared | Add data to route config |
| Token not attached | Interceptor not registered | Add to provideHttpClient(withInterceptors([...])) |
Real-World Examples
1. Auth Guard with Return URL
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
2. Login Component Redirect
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
3. Role Guard
const requiredRoles = route.data['roles'] as string[];
if (!authService.hasAnyRole(requiredRoles)) return router.createUrlTree(['/unauthorized']);
4. CanActivateChild
canActivateChild: [adminGuard]
5. CanMatch Feature Flag
canMatch: [() => inject(FeatureService).isEnabled('beta')]
6. Async Token Validation
const isValid = await authService.validateToken();
7. Logout Flow
this.authService.logout();
this.router.navigate(['/login']);
8. Token Interceptor
req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
9. Guard Test
await harness.navigateByUrl('/admin');
expect(TestBed.inject(Router).url).toContain('/login');
10. Signal-Based Auth State
readonly isAuthenticated = computed(() => this._user() !== null);
Visual
The Authentication Flow
┌──────────────────────────────────────────────┐
│ AUTHENTICATION FLOW │
│ │
│ User navigates to /admin │
│ │ │
│ ▼ │
│ authGuard runs │
│ │ │
│ ├─ Authenticated? │
│ │ ├─ YES → return true → activate │
│ │ └─ NO → return UrlTree │
│ │ │ │
│ │ ▼ │
│ │ /login?returnUrl=/admin │
│ │ │ │
│ │ ▼ │
│ │ User logs in │
│ │ │ │
│ │ ▼ │
│ │ navigateByUrl(returnUrl) │
│ │ │ │
│ │ ▼ │
│ └──────── /admin (now allowed) │
│ │
└──────────────────────────────────────────────┘
The Guard Hierarchy
┌──────────────────────────────────────────────┐
│ GUARD HIERARCHY │
│ │
│ CanMatch │
│ └─ Should this route be considered? │
│ └─ If false, try other routes │
│ │
│ CanActivate │
│ └─ Can this route activate? │
│ └─ If false, block navigation │
│ │
│ CanActivateChild │
│ └─ Can children activate? │
│ └─ Runs for every child route │
│ │
│ CanDeactivate │
│ └─ Can this route deactivate? │
│ └─ If false, stay on current route │
│ │
└──────────────────────────────────────────────┘
The Return URL Pattern
┌──────────────────────────────────────────────┐
│ RETURN URL PATTERN │
│ │
│ 1. User clicks link to /purchase/42 │
│ 2. Guard blocks, redirects to: │
│ /login?returnUrl=%2Fpurchase%2F42 │
│ 3. User logs in │
│ 4. Login component reads returnUrl │
│ 5. router.navigateByUrl('/purchase/42') │
│ │
│ Without this: user lands on dashboard, │
│ loses their intended destination. │
│ │
└──────────────────────────────────────────────┘
Token Storage and Interceptors
┌──────────────────────────────────────────────┐
│ TOKEN STORAGE & INTERCEPTORS │
│ │
│ AuthService: │
│ login() → store token in memory │
│ │
│ Interceptor: │
│ Every HTTP request → attach token │
│ │
│ Guard: │
│ Check isAuthenticated() → allow/redirect │
│ │
│ Server: │
│ Verify token on every request │
│ The client guard is UX, not security. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Guard type | Functional (CanActivateFn, CanActivateChildFn, CanDeactivateFn, CanMatchFn) |
| Redirect mechanism | Return UrlTree or RedirectCommand |
| Return URL | state.url as returnUrl query param |
| Role check | route.data['roles'] + hasAnyRole() |
| Auth state | BehaviorSubject or signal |
| Token attachment | HTTP interceptor |
| Guard test | RouterTestingHarness |
| Security | Server-side enforcement required |
Key takeaways:
- Route guards are the client-side mechanism for access control, but they are not a security boundary. The Angular documentation is explicit: “Never rely on client-side guards as the sole source of access control. Always enforce user authorization server-side” .
- Functional guards are the modern API. A guard is a function typed as
CanActivateFn,CanActivateChildFn,CanDeactivateFn, orCanMatchFnthat usesinject()and returnstrue,false, aUrlTree, or an observable/promise of these . - Return a
UrlTreefor redirects, notfalseplusrouter.navigate(). TheUrlTreeapproach lets the router cancel the current navigation and start a new one in a single step. Callingnavigate()inside the guard and then returningfalseis the older, more error-prone pattern . - The return URL pattern preserves the user’s intended destination. The guard captures
state.urland passes it as a query parameter. The login component reads it after successful authentication and navigates to it. The|| '/'fallback handles direct navigation to the login page . - Role guards read requirements from route data. A single guard can serve every route if the required roles are declared in
data: { roles: ['admin'] }. This avoids writing a separate guard for each role . CanActivateChildprotects nested routes without repetition. Declare it once on the parent route, and it runs for every child. This is the correct pattern for admin sections, account settings, and any nested feature area .- Async guards support token validation. When authentication requires a server round-trip, the guard can return a promise or observable. The router waits for it to resolve before continuing .
- Attach the token with an HTTP interceptor. The interceptor clones every outgoing request and adds the
Authorizationheader. This keeps the token logic in one place and out of every service .
Remember: Route protection in Angular is a two-layer system. The client-side guard prevents the application from attempting to render protected content and redirects the user gracefully. The server-side check prevents anyone from actually accessing the data. The guard is for the user experience; the server is for security. Use functional guards, return UrlTree for redirects, preserve the return URL through login, declare role requirements in route data, and attach tokens with an interceptor. Test the guards with RouterTestingHarness. The pattern is the same for every protected application — authentication, authorization, and the return URL that makes the redirect feel invisible.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!