Skip to content
Angular ng security 5 min read

Authentication & Authorization

Authentication answers who is this user? while authorization answers what are they allowed to do? In a single-page Angular app both happen on the client, but the real security boundary always lives on the server. Angular’s job is to manage a session token, attach it to outgoing requests, and steer users away from views they cannot use. This page walks through a modern JWT flow using signals, a functional HTTP interceptor, and role-aware route guards.

Authenticating and storing the token

Authentication typically means exchanging credentials for a JSON Web Token (JWT). The token is a signed, base64url-encoded string the server issues on login; the client sends it back on every subsequent request to prove identity. Model the session as a service that holds the current user in a signal so the rest of the app can react to login state.

import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs';

interface AuthResponse {
  token: string;
  user: { id: string; email: string; roles: string[] };
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private token = signal<string | null>(localStorage.getItem('token'));

  readonly isLoggedIn = computed(() => this.token() !== null);
  readonly roles = computed(() => this.decode()?.roles ?? []);

  login(email: string, password: string) {
    return this.http
      .post<AuthResponse>('/api/auth/login', { email, password })
      .pipe(tap((res) => this.setToken(res.token)));
  }

  logout() {
    localStorage.removeItem('token');
    this.token.set(null);
  }

  getToken() {
    return this.token();
  }

  private setToken(value: string) {
    localStorage.setItem('token', value);
    this.token.set(value);
  }

  private decode(): { roles: string[]; exp: number } | null {
    const t = this.token();
    if (!t) return null;
    try {
      return JSON.parse(atob(t.split('.')[1]));
    } catch {
      return null;
    }
  }
}

Tip: localStorage is convenient but readable by any script on the page, so a single XSS bug exposes the token. For sensitive apps prefer a Secure, HttpOnly, SameSite=Strict cookie set by the server — the JS code never touches the token at all.

Building a login component

With the service in place, the login form just calls login() and navigates on success. Use a signal for the error message and the new control flow syntax in the template.

import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from './auth.service';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [FormsModule],
  template: `
    <form (ngSubmit)="submit()">
      <input name="email" [(ngModel)]="email" placeholder="Email" />
      <input name="password" type="password" [(ngModel)]="password" />
      <button [disabled]="loading()">Sign in</button>
    </form>
    @if (error()) {
      <p class="error">{{ error() }}</p>
    }
  `,
})
export class LoginComponent {
  private auth = inject(AuthService);
  private router = inject(Router);

  email = '';
  password = '';
  loading = signal(false);
  error = signal('');

  submit() {
    this.loading.set(true);
    this.auth.login(this.email, this.password).subscribe({
      next: () => this.router.navigate(['/dashboard']),
      error: () => {
        this.error.set('Invalid email or password');
        this.loading.set(false);
      },
    });
  }
}

Attaching the token with an interceptor

Rather than wiring the Authorization header into every request by hand, register a functional HTTP interceptor. It clones each outgoing request, adds the bearer token, and forwards it. A second responsibility is handling 401 Unauthorized responses by logging the user out.

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  const token = auth.getToken();

  const authReq = token
    ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : req;

  return next(authReq).pipe(
    catchError((err) => {
      if (err.status === 401) {
        auth.logout();
        router.navigate(['/login']);
      }
      return throwError(() => err);
    })
  );
};

Register it alongside provideHttpClient in your application config:

import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { authInterceptor } from './auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([authInterceptor]))],
};

Guarding routes by role

Authorization on the client means showing or hiding routes. A CanActivateFn runs before navigation and returns true, false, or a UrlTree redirect. The first guard checks that the user is authenticated; a factory produces role-specific guards.

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

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isLoggedIn() ? true : router.createUrlTree(['/login']);
};

export const roleGuard = (required: string): CanActivateFn => {
  return () => {
    const auth = inject(AuthService);
    const router = inject(Router);
    return auth.roles().includes(required)
      ? true
      : router.createUrlTree(['/forbidden']);
  };
};

Wire the guards into the route table. Guards compose, so an admin route can require both authentication and the admin role:

import { Routes } from '@angular/router';
import { authGuard, roleGuard } from './auth.guards';

export const routes: Routes = [
  { path: 'login', loadComponent: () => import('./login.component') },
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadComponent: () => import('./dashboard.component'),
  },
  {
    path: 'admin',
    canActivate: [authGuard, roleGuard('admin')],
    loadComponent: () => import('./admin.component'),
  },
];

You can mirror the same role check in templates to hide UI such as an “Edit” button:

@if (auth.roles().includes('admin')) {
  <button (click)="edit()">Edit</button>
}

Output (admin user navigating to /admin):

Navigation allowed → AdminComponent rendered
GET /api/admin/stats  →  200 OK  (Authorization: Bearer eyJhbGciOi...)
MechanismRuns wherePurpose
AuthService signalClientTracks login state reactively
authInterceptorClientAttaches token, handles 401
CanActivateFn guardClientBlocks navigation by auth/role
Server authorizationServerThe only real security boundary

Warning: Guards and template checks are UX, not protection. Anyone can read your bundle and call the API directly, so every endpoint must independently verify the token and the user’s role on the server.

Best Practices

  • Store tokens in HttpOnly cookies when you can; if you must use localStorage, harden the app against XSS first.
  • Keep session state in a signal so login, logout, and role changes propagate reactively across the UI.
  • Centralize header attachment and 401 handling in a single functional interceptor rather than scattering it across services.
  • Use functional guards (CanActivateFn) and compose them — pair authGuard with a roleGuard for protected admin areas.
  • Never trust the client: re-validate authentication and authorization on every server endpoint.
  • Refresh or rotate short-lived access tokens and clear them immediately on logout to limit the blast radius of a leak.
Last updated June 14, 2026
Was this helpful?