Authentication, Interceptors and Guards
Before you start
You need: HttpClient (Article 09) and routing (Article 05).
Time: about 50 minutes, plus the practice.
Learning objective
Build a complete sign-in flow that attaches tokens, protects routes, handles expiry, and never mistakes hidden UI for security.
Topics
- The authentication flow
- An auth service with signals
- Token storage and its trade-off
- Functional interceptors
- Handling 401 centrally
- Route guards
- Role-based UI
- Unsaved-changes guards
- The security boundary
The flow
1. User submits credentials
2. POST /api/auth/login
3. API returns a JWT
4. Store the token
5. Every request carries it via an interceptor
6. Guards block routes for signed-out users
7. A 401 clears the token and redirects to login
8. Sign out clears everything
The auth service
export interface LoginRequest { email: string; password: string; }
export interface TokenResult { accessToken: string; expiresAt: string; }
export interface CurrentUser {
publicId: string;
name: string;
role: 'Admin' | 'Principal' | 'Teacher' | 'Staff' | 'Student';
schoolId: number;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private static readonly TOKEN_KEY = 'authToken';
private readonly userSignal = signal<CurrentUser | null>(this.readUserFromToken());
readonly user = this.userSignal.asReadonly();
readonly isAuthenticated = computed(() => this.userSignal() !== null);
readonly role = computed(() => this.userSignal()?.role ?? null);
login(request: LoginRequest): Observable<CurrentUser> {
return this.http.post<TokenResult>(`${environment.apiUrl}/api/auth/login`, request).pipe(
map(result => {
this.storeToken(result.accessToken);
const user = this.readUserFromToken();
if (!user) {
throw new Error('The token could not be read.');
}
this.userSignal.set(user);
return user;
})
);
}
logout(): void {
this.clearToken();
this.userSignal.set(null);
this.router.navigate(['/login']);
}
getToken(): string | null {
try {
const token = localStorage.getItem(AuthService.TOKEN_KEY);
if (!token || this.isExpired(token)) {
this.clearToken();
return null;
}
return token;
} catch {
return null; // private mode, or storage disabled
}
}
hasRole(...roles: string[]): boolean {
const current = this.userSignal();
return current !== null && roles.includes(current.role);
}
private storeToken(token: string): void {
try {
localStorage.setItem(AuthService.TOKEN_KEY, token);
} catch {
// storage unavailable — the session lasts until reload
}
}
private clearToken(): void {
try {
localStorage.removeItem(AuthService.TOKEN_KEY);
} catch { /* ignore */ }
}
private isExpired(token: string): boolean {
const payload = this.decode(token);
return !payload || payload.exp * 1000 < Date.now();
}
private readUserFromToken(): CurrentUser | null {
const token = localStorage.getItem(AuthService.TOKEN_KEY);
if (!token || this.isExpired(token)) {
return null;
}
const payload = this.decode(token);
if (!payload) {
return null;
}
return {
publicId: payload.sub,
name: payload.name ?? '',
role: payload.role,
schoolId: Number(payload.SchoolId)
};
}
private decode(token: string): any | null {
try {
const [, payload] = token.split('.');
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}
}
Four things to note.
Reading the user from the token on construction restores the session on a page refresh. Without it, refreshing signs the user out.
Expiry is checked on every read. A token that expired hours ago is still in storage, and every call returns 401 — the application must clear it rather than loop.
Every localStorage access is wrapped in try/catch. Safari in private mode and a full quota both throw on setItem.
Decoding the token is for the interface only. The client can read the claims; only the server's signature check makes them trustworthy. Role and schoolId here decide what to show — never what is allowed.
Token storage
| Store | Readable by JavaScript | Survives refresh | Risk |
|---|---|---|---|
localStorage | Yes | Yes | XSS can steal it |
sessionStorage | Yes | Per tab | Same, narrower |
| In-memory | No | No | Lost on refresh |
HttpOnly cookie | No | Yes | Needs CSRF protection |
A token in localStorage is readable by any script on the page, including a compromised third-party library. That is the standard XSS token-theft path.
An HttpOnly cookie cannot be read by JavaScript at all, which removes that risk and requires CSRF protection instead. Both are used in production; knowing the trade-off is the interview answer.
Whichever you choose, the mitigation is the same: never render untrusted data as HTML. Angular's interpolation encodes by default; [innerHTML] bypasses that and is where the vulnerability comes from.
Short token lifetimes with a refresh token limit the damage — a stolen token expires in an hour rather than a week.
Functional interceptors
// core/interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
// Do not attach the token to the login endpoint or to third parties
if (!token || !req.url.startsWith(environment.apiUrl)) {
return next(req);
}
return next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
}));
};
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
Interceptors run in registration order on the way out and in reverse on the way back.
HttpRequest is immutable — req.clone() is the only way to modify it. Assigning to req.headers does nothing, silently.
The URL check matters. Without it, the token is attached to every request the application makes, including to third-party APIs — handing your credential to someone else's server.
The error interceptor
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const router = inject(Router);
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401 && !req.url.includes('/auth/login')) {
authService.logout();
router.navigate(['/login'], {
queryParams: { returnUrl: router.url }
});
}
if (err.status === 403) {
router.navigate(['/access-denied']);
}
return throwError(() => err);
})
);
};
Handling 401 once, centrally, means no component needs to think about session expiry.
Excluding the login endpoint is essential. A 401 from login means wrong credentials, and redirecting to login from login is an infinite loop.
throwError(() => err) rethrows, so the calling component can still show a field-level message. Swallowing the error here leaves the component thinking the request succeeded.
returnUrl sends the user back to where they were after signing in.
Refresh token flow
let refreshInProgress = false;
const refreshSubject = new BehaviorSubject<string | null>(null);
export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status !== 401 || req.url.includes('/auth/')) {
return throwError(() => err);
}
if (refreshInProgress) {
// Wait for the in-flight refresh, then retry
return refreshSubject.pipe(
filter(token => token !== null),
take(1),
switchMap(token => next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
})))
);
}
refreshInProgress = true;
refreshSubject.next(null);
return authService.refresh().pipe(
switchMap(token => {
refreshInProgress = false;
refreshSubject.next(token);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
}),
catchError(refreshError => {
refreshInProgress = false;
authService.logout();
return throwError(() => refreshError);
})
);
})
);
};
The queue is the point. Without it, ten concurrent requests hitting an expired token trigger ten refresh calls, and nine of them fail because the first rotated the refresh token. The BehaviorSubject makes the others wait for the one in flight.
Other useful interceptors
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loadingService = inject(LoadingService);
loadingService.start();
return next(req).pipe(finalize(() => loadingService.stop()));
};
export const correlationInterceptor: HttpInterceptorFn = (req, next) =>
next(req.clone({ setHeaders: { 'X-Correlation-Id': crypto.randomUUID() } }));
finalize runs on success and error. Stopping the spinner only in the success path leaves it spinning forever after a failure.
Route guards
// core/guards/auth.guard.ts
import { CanActivateFn, Router } from '@angular/router';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
};
export const roleGuard = (...roles: string[]): CanActivateFn => {
return () => {
const authService = inject(AuthService);
const router = inject(Router);
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login']);
}
return authService.hasRole(...roles) || router.createUrlTree(['/access-denied']);
};
};
export const routes: Routes = [
{ path: 'login', loadComponent: () => import('./auth/login.component') },
{
path: 'students',
canActivate: [authGuard],
loadChildren: () => import('./features/students/students.routes').then(m => m.STUDENT_ROUTES)
},
{
path: 'admin',
canActivate: [authGuard, roleGuard('Admin')],
loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes)
}
];
Return a UrlTree, not router.navigate(). Returning false and navigating separately causes two navigations to race; a UrlTree tells the router where to redirect as part of the same navigation.
| Guard | Runs |
|---|---|
CanActivateFn | Before activating a route |
CanActivateChildFn | Before activating any child |
CanMatchFn | Before the route is even matched |
CanDeactivateFn | Before leaving a route |
CanMatchFn is better than CanActivateFn for a lazy feature: it runs before the chunk downloads, so an unauthorised user never fetches the code.
export const adminMatchGuard: CanMatchFn = () => {
const authService = inject(AuthService);
return authService.hasRole('Admin') || inject(Router).createUrlTree(['/access-denied']);
};
Role-based UI
@if (auth.hasRole('Admin', 'Principal')) {
<button type="button" (click)="deleteStudent()">Delete</button>
}
@Directive({ selector: '[appHasRole]', standalone: true })
export class HasRoleDirective {
private readonly templateRef = inject(TemplateRef<unknown>);
private readonly viewContainer = inject(ViewContainerRef);
private readonly auth = inject(AuthService);
@Input() set appHasRole(roles: string[]) {
this.viewContainer.clear();
if (this.auth.hasRole(...roles)) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
}
}
<button *appHasRole="['Admin']" (click)="deleteStudent()">Delete</button>
The security boundary
Everything in this article improves the interface. None of it is a security control.
| Client-side | What it actually does |
|---|---|
| Route guard | Stops a signed-out user seeing a blank page |
| Hidden button | Reduces clutter and confusion |
| Role check | Shows relevant options |
| Token expiry check | Avoids a pointless request |
All of it is bypassable: guards can be skipped by calling the API directly, hidden buttons still have endpoints, and the token's claims are readable and editable in DevTools — the signature is what the server validates.
The server must enforce every rule independently:
[HttpDelete("{publicId:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid publicId, CancellationToken ct)
{
var schoolId = User.GetSchoolId(); // from the token, never the request
var deleted = await _studentService.DeactivateAsync(schoolId, publicId, ct);
return deleted ? NoContent() : NotFound();
}
A guard that hides the delete button and an API that accepts a delete from anyone is an application with no access control at all.
Unsaved-changes guard
export interface CanComponentDeactivate {
canDeactivate(): boolean | Observable<boolean>;
}
export const unsavedChangesGuard: CanDeactivateFn<CanComponentDeactivate> = component =>
component.canDeactivate();
export class StudentFormComponent implements CanComponentDeactivate {
canDeactivate(): boolean {
if (this.form.pristine || this.saved()) {
return true;
}
return confirm('You have unsaved changes. Leave this page?');
}
}
{ path: 'students/new', loadComponent: () => import('./student-form.component'),
canDeactivate: [unsavedChangesGuard] }
Checking this.saved() matters: without it, navigating away after a successful save still prompts, because the form is dirty.
confirm() does not cover a browser refresh or tab close — add a beforeunload listener for that.
Login component
export class LoginComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly fb = inject(FormBuilder);
readonly form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required]
});
readonly signingIn = signal(false);
readonly error = signal<string | null>(null);
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.signingIn.set(true);
this.error.set(null);
this.auth.login(this.form.getRawValue()).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/students';
this.router.navigateByUrl(returnUrl);
},
error: (err: HttpErrorResponse) => {
this.signingIn.set(false);
this.error.set(err.status === 401
? 'Incorrect email or password.'
: 'Could not sign in. Please try again.');
}
});
}
}
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" autocomplete="username">
<label for="password">Password</label>
<input id="password" type="password" formControlName="password"
autocomplete="current-password">
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
<button type="submit" [disabled]="signingIn()">
{{ signingIn() ? 'Signing in…' : 'Sign in' }}
</button>
</form>
One message for both wrong email and wrong password. "No such user" tells an attacker which addresses are registered — the same rule as on the server side.
autocomplete="username" and current-password let password managers work, which measurably improves password quality.
returnUrl from the query string sends the user back to what they were trying to reach.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Token not attached | Interceptor not registered | Add it to provideHttpClient(withInterceptors(...)) |
| Infinite redirect loop to login | The guard also protects the login route | Exclude it |
| User stays "logged in" after expiry | Only checked the token exists, not exp | Decode and check |
| A blocked user still reaches the data | Guard hides the route, not the endpoint | The API must enforce it |
returnUrl lost after login | Not captured before redirecting | Store it in the guard |
A route guard is interface, not security. It stops a menu item appearing; it does not stop anyone calling the endpoint from Postman.
Common mistakes
- Modifying
reqinstead of usingreq.clone() - Attaching the token to every URL, including third parties
- No URL exclusion for the login endpoint, causing a 401 redirect loop
- Swallowing the error in an interceptor instead of rethrowing
- No refresh queue, so concurrent 401s trigger several refreshes
router.navigate()in a guard instead of returning aUrlTreeCanActivateon a lazy route whereCanMatchwould avoid the download- Not restoring the session from the token on refresh
- Never checking token expiry
- No try/catch around
localStorage - Treating a guard or a hidden button as authorisation
- Trusting decoded token claims for anything but display
- Different login messages for unknown email and wrong password
- No
disabledon the sign-in button finalizemissing, leaving a spinner after an error- An unsaved-changes guard that fires after a successful save
Practice
The course assignment is add an authentication UI flow.
- Build
AuthServicewith signals, token storage, expiry checking and try/catch. - Build the login component with
returnUrlhandling. - Sign in, then refresh the page. Confirm the session survives. Remove the constructor restore and confirm it does not.
- Write
authInterceptor. Confirm the header in the Network tab. - Modify
req.headersdirectly instead of cloning. Confirm the header is absent. - Remove the URL check and call a third-party API. Confirm your token was sent.
- Write
errorInterceptorhandling 401. Expire the token manually and confirm the redirect. - Remove the login-endpoint exclusion and sign in with wrong credentials. Confirm the redirect loop.
- Write
authGuardreturning aUrlTree. Navigate to a protected route signed out. - Change it to return
falseplusrouter.navigate(). Observe the racing navigations. - Write
roleGuard('Admin'). Sign in as a Teacher and confirm the redirect. - Convert a lazy admin route to
CanMatch. Confirm the chunk is not downloaded for a Teacher. - Hide a delete button with
*appHasRole. Then call the delete endpoint directly with the Teacher's token — confirm the server rejects it, and reason about what would happen if it did not. - Edit the token payload in DevTools to change your role. Confirm the UI changes and the API still refuses.
- Add the unsaved-changes guard. Save successfully and navigate away — confirm no prompt.
- Fire ten concurrent requests with an expired token and no refresh queue. Count the refresh calls.
Exercises 13 and 14 are the ones that make the security boundary concrete.
You can now
- Build a complete authentication flow
- Attach tokens with an interceptor
- Handle 401 centrally and preserve
returnUrl - Check token expiry, not just presence
- State precisely which parts are security and which are interface
Review questions
- Why must an interceptor use
req.clone()? - Why exclude the login endpoint from 401 handling?
- Why return a
UrlTreefrom a guard rather than callingnavigate()? - Why is a route guard not a security control?
Next: Guided Angular project