Angular in Practice #2: Authentication and HTTP Interceptor
In #1 Project Skeleton, we laid down routing, the layout, and an empty dashboard screen. But a dashboard without login is essentially a static page. In this post, we layer the auth flow on top — login screen, token storage, route protection, and an HTTP Interceptor that automatically attaches the token to every request.
Backend assumptions #
The code in this post assumes the following two endpoints exist.
POST /api/auth/login { email, password } → { accessToken: string }
GET /api/auth/me Authorization: Bearer ... → { id, email, name }Whether the backend was built with the Modern Python Backend (FastAPI) series or with Django REST Framework, as long as the shape matches, it plugs in directly. This is the common pattern of carrying a JWT (Access Token) in the header. The Refresh Token gets one short paragraph at the end. We assume environment.apiBase is already set up in #1.
Login screen — Reactive Forms #
First, the login component. The same pattern from Intermediate #1 Reactive Forms — a FormGroup with two fields + validation + (ngSubmit).
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../../auth/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
protected readonly loading = signal(false);
protected readonly errorMsg = signal<string | null>(null);
protected readonly form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading.set(true);
this.errorMsg.set(null);
const { email, password } = this.form.getRawValue();
this.auth.login(email, password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/dashboard';
this.router.navigateByUrl(returnUrl);
},
error: (err) => {
this.errorMsg.set(err?.error?.message ?? 'Login failed.');
this.loading.set(false);
},
});
}
}The template is plain — two inputs, validation messages, a server error, and a submit button. The point is that this component knows its responsibility precisely. It takes input, calls AuthService.login(), and on success hands off to the router for the next step. It doesn’t store tokens or touch headers. That’s the next layer’s job.
AuthService — the single place that holds the token #
The auth state gathers in one place for the whole app. Following the Basics #5 Service and DI pattern, we make a singleton with providedIn: 'root' and keep the token and user info there as signals.
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { environment } from '../../environments/environment';
export interface User {
id: number;
email: string;
name: string;
}
const TOKEN_KEY = 'auth_token';
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
// Restore token from localStorage on app start
private readonly _token = signal<string | null>(localStorage.getItem(TOKEN_KEY));
private readonly _user = signal<User | null>(null);
readonly token = this._token.asReadonly();
readonly user = this._user.asReadonly();
readonly isLoggedIn = computed(() => this._token() !== null);
login(email: string, password: string): Observable<{ accessToken: string }> {
return this.http
.post<{ accessToken: string }>(`${environment.apiBase}/auth/login`, { email, password })
.pipe(
tap((res) => this.setToken(res.accessToken)),
tap(() => this.fetchMe().subscribe()),
);
}
fetchMe(): Observable<User> {
return this.http
.get<User>(`${environment.apiBase}/auth/me`)
.pipe(tap((user) => this._user.set(user)));
}
logout(): void {
this._token.set(null);
this._user.set(null);
localStorage.removeItem(TOKEN_KEY);
}
private setToken(token: string): void {
this._token.set(token);
localStorage.setItem(TOKEN_KEY, token);
}
}Three points to call out.
_tokenis restored fromlocalStorageonce at app start. That’s why the logged-in state survives a refresh.- There’s only one path to store the token:
setToken(). A small guardrail to keep the signal and localStorage always in sync. To prevent outside code from touching the signal directly,_tokenis private, and onlyasReadonly()is exposed. isLoggedInis derived as acomputed. When the token changes, it updates automatically, and guards and header components see the same single source of truth.
localStorage is often called out as vulnerable to XSS. The safer choice is to have the backend send it down as an HttpOnly; Secure; SameSite=Strict cookie, in which case the client never has to touch the token at all. That said, scenarios like SPA + a separately-domained backend + shared mobile apps still commonly use header + localStorage, so know the trade-offs and choose accordingly. This post follows the most common header style.Login flow — one more pass through #
Pulling the pieces together — /login form submit → AuthService.login() → on POST success, tap saves the token to the signal + localStorage → then fetchMe() fills in user info → the component navigates to returnUrl (or default /dashboard). The component never touches the token directly. Saving and restoring are entirely AuthService’s responsibility, and the component is only the caller.
Auth Guard — protected routes #
Without a token, /dashboard should be off-limits. We bring in the functional guard pattern from Intermediate #6 Guards as-is.
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
// Preserve the originally-requested URL as returnUrl
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};Attach it on the route like this.
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'login',
loadComponent: () => import('./pages/login/login.component').then(m => m.LoginComponent),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./pages/dashboard/dashboard.component').then(m => m.DashboardComponent),
},
{ path: '**', redirectTo: 'dashboard' },
];When unauthenticated and trying to enter /dashboard/orders, the guard redirects to /login?returnUrl=%2Fdashboard%2Forders, and after login the LoginComponent reads that returnUrl and sends the user back to exactly where they were. From the user’s perspective it feels natural — “oh, I’m right back where I was.”
HTTP Interceptor — auto-attaching the header #
Now for authenticated requests. Manually attaching headers: { Authorization: 'Bearer ...' } to every call is no answer. The functional Interceptor introduced in one line in Basics #7 shines exactly here.
import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
// Don't attach the token to the login request itself
if (!token || req.url.includes('/auth/login')) {
return next(req);
}
const cloned = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(cloned);
};HttpRequest is immutable, so instead of modifying it directly, we make a new request with req.clone() and pass it through next(). Registration is one line in app.config.ts.
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};This single line auto-attaches the token to every HTTP request in the app. UserService, OrderService, and every service to come can just call http.get() without thinking about it.
Auto-handling 401 — cleaning up an expired token #
Tokens can expire, or be invalidated by the backend. The result usually arrives as a 401 Unauthorized. Since interceptors can also intercept responses, let’s handle this in one place.
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
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.token();
const outgoing =
!token || req.url.includes('/auth/login')
? req
: req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
return next(outgoing).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401 && !req.url.includes('/auth/login')) {
auth.logout();
router.navigateByUrl('/login');
}
return throwError(() => err);
}),
);
};If you’re on a screen with an expired token and the API drops a 401, every component, blissfully unaware, ends up cleanly back at the login screen. No need to repeat 401 handling in every service. This is the essence of an Interceptor — gathering cross-cutting concerns into one place.
Showing user info — name and logout in the header #
Since we’ve already filled in AuthService.user(), the header component just consumes it.
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-header',
standalone: true,
template: `
<header class="app-header">
<h1>Dashboard</h1>
@if (auth.user(); as user) {
<div class="user-area">
<span>{{ user.name }}</span>
<button type="button" (click)="logout()">Log out</button>
</div>
}
</header>
`,
})
export class HeaderComponent {
protected auth = inject(AuthService);
private router = inject(Router);
logout() {
this.auth.logout();
this.router.navigateByUrl('/login');
}
}@if (auth.user(); as user) in one line — only when user info is filled in does the name and logout button appear. When the signal changes, the template updates itself, with no subscribe or OnInit.
That said, right after a page refresh, there’s a moment where the token is restored but user is still empty. The usual fix is to call fetchMe() once at app boot in AppComponent.
export class AppComponent {
private auth = inject(AuthService);
constructor() {
if (this.auth.isLoggedIn()) {
this.auth.fetchMe().subscribe({
error: () => this.auth.logout(), // If the token is dead, clean up
});
}
}
}The case where the token is alive locally but invalidated on the backend gets caught here in one go.
Refresh Token — the pattern, briefly #
The shorter the Access Token’s lifespan, the better for security — but from the user’s perspective, getting logged out repeatedly is annoying. So the common pattern is to keep a Refresh Token companion: when the Access Token expires, quietly grab a new Access via Refresh and continue.
The standard pattern: when the Interceptor hits a 401, instead of logging out immediately, try /api/auth/refresh once, and on success retry the originally failed request with the new token. To handle concurrent requests, an RxJS Subject is typically used to issue the refresh request only once and have the rest wait for the result. The code gets quite long, so we don’t cover it in this post, but the progression to keep in your head is — “401 → unconditional logout” as step one → “401 → try refresh once” as step two.
Wrapping up #
In this post, we filled the empty skeleton of #1 with an auth flow.
- Login screen: Reactive Forms + the role of just calling
AuthService.login() - AuthService: a single place that holds the token and user as signals, with localStorage persistence
- Auth Guard: functional
CanActivateFn; if unauthenticated, preservereturnUrland go to/login - HTTP Interceptor: auto-attach
Bearerheader on every request + auto-logout on 401 - Header: bind
auth.user()signal directly, restore viafetchMe()on refresh
Routing, layout, and auth are all in place. The next step is real data. In the next post, “Angular in Practice #3: Forms and API CRUD,” we’ll follow the full flow of CRUDing a resource (e.g., posts or tasks) within the protected area — list view, detail + edit form, create/update/delete, and even optimistic updates. With authenticated requests passing through automatically, it’s time to focus on domain logic.