Route Guards End-to-End — Protecting Admin Routes with Roles

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>
Note: Route guards in Angular 18 are functional (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.
Tip: Sanitise the 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.
Warning: Route guards in Angular protect the Angular routing layer — they do not prevent direct HTTP requests to the API. A determined user can bypass Angular guards by opening the browser’s Network tab and making API calls directly. The real security enforcement happens on the API: every endpoint with [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.

🧠 Test Yourself

A user visits /admin/posts while not logged in. The authGuard redirects to /auth/login?returnUrl=/admin/posts. After login, what URL does the app navigate to?