Angular route guards protect routes from unauthorized access. For the BlogApp, two guards work in tandem: authGuard (is the user logged in?) and adminGuard (does the user have the Admin role?). Guards read from AuthService signals — after the APP_INITIALIZER session restore completes, the signals accurately reflect the user’s authentication state. The returnUrl pattern preserves the user’s intended destination through the login flow.
Route Guards Implementation
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
// ── Auth guard — redirect to login if not authenticated ───────────────────
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) return true;
// Preserve the attempted URL for post-login redirect
return router.createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url }
});
};
// ── Admin guard — check for Admin role ────────────────────────────────────
export const adminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.hasRole('Admin')) return true;
// Authenticated but not admin — show forbidden
return router.createUrlTree(['/forbidden']);
};
// ── Apply guards in routes ─────────────────────────────────────────────────
export const routes: Routes = [
{ path: '', redirectTo: 'posts', pathMatch: 'full' },
{ path: 'posts', loadComponent: () => import('./features/posts/post-list.component')
.then(m => m.PostListComponent) },
{ path: 'auth', loadChildren: () => import('./features/auth/auth.routes')
.then(m => m.authRoutes) },
// Protected admin section — both guards applied
{
path: 'admin',
canActivate: [authGuard, adminGuard],
loadChildren: () => import('./features/admin/admin.routes')
.then(m => m.adminRoutes),
},
{ path: 'forbidden', loadComponent: () => import('./shared/forbidden.component')
.then(m => m.ForbiddenComponent) },
];
// ── LoginComponent — handle returnUrl redirect after login ─────────────────
@Component({ standalone: true, template: `...` })
export class LoginComponent {
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
form = inject(FormBuilder).nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
isSaving = signal(false);
error = signal('');
onSubmit(): void {
if (this.form.invalid) return;
this.isSaving.set(true);
const { email, password } = this.form.getRawValue();
this.auth.login(email, password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/posts';
this.router.navigateByUrl(returnUrl); // redirect to original destination
},
error: () => {
this.isSaving.set(false);
this.error.set('Invalid email or password.');
},
});
}
}
// ── HasRole directive — show/hide elements based on role ──────────────────
@Directive({ selector: '[appHasRole]', standalone: true })
export class HasRoleDirective implements OnInit {
private auth = inject(AuthService);
private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef);
private destroyRef = inject(DestroyRef);
@Input({ required: true, alias: 'appHasRole' }) role!: string | string[];
ngOnInit() {
effect(() => {
const roles = Array.isArray(this.role) ? this.role : [this.role];
const show = roles.some(r => this.auth.hasRole(r));
this.viewContainer.clear();
if (show) this.viewContainer.createEmbeddedView(this.templateRef);
}, { injector: ... });
}
}
// Usage: <a *appHasRole="'Admin'" routerLink="/admin">Admin</a>
CanActivateFn) — plain functions that use inject() to access services. They run synchronously (using the cached Signal values from AuthService) after the APP_INITIALIZER has restored the session. The guard reads auth.isLoggedIn() which returns the current Signal value immediately — no async operations needed. This synchronous check is possible because session restoration happens in the APP_INITIALIZER before routing begins.returnUrl parameter before using it in navigateByUrl(returnUrl). An attacker could craft a URL like /auth/login?returnUrl=https://evil.com — after login, the app redirects the user to an external site. Validate that the returnUrl starts with / (relative URL): const safeUrl = returnUrl.startsWith('/') ? returnUrl : '/posts'. Angular’s navigateByUrl() only navigates within the app for relative URLs, but defensive coding is best practice.[Authorize] validates the JWT, and role requirements with [Authorize(Roles = "Admin")] ensure only admins access admin operations. Angular guards are a UX mechanism, not a security boundary.Common Mistakes
Mistake 1 — Relying on route guards for security (API must also enforce access)
❌ Wrong — Angular admin guard hides the link but no [Authorize(Roles=”Admin”)] on the API; anyone can call admin endpoints directly.
✅ Correct — both Angular guards (UX) and API [Authorize] attributes (security) are required.
Mistake 2 — Not validating returnUrl (open redirect vulnerability)
❌ Wrong — navigateByUrl(returnUrl) without sanitisation; attacker redirects user to external site after login.
✅ Correct — validate returnUrl starts with /; fallback to /posts for invalid values.