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:
localStorageis convenient but readable by any script on the page, so a single XSS bug exposes the token. For sensitive apps prefer aSecure,HttpOnly,SameSite=Strictcookie 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...)
| Mechanism | Runs where | Purpose |
|---|---|---|
AuthService signal | Client | Tracks login state reactively |
authInterceptor | Client | Attaches token, handles 401 |
CanActivateFn guard | Client | Blocks navigation by auth/role |
| Server authorization | Server | The 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
HttpOnlycookies when you can; if you must uselocalStorage, 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 — pairauthGuardwith aroleGuardfor 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.