Skip to main content
Published / updated

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

StoreReadable by JavaScriptSurvives refreshRisk
localStorageYesYesXSS can steal it
sessionStorageYesPer tabSame, narrower
In-memoryNoNoLost on refresh
HttpOnly cookieNoYesNeeds 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 immutablereq.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.

GuardRuns
CanActivateFnBefore activating a route
CanActivateChildFnBefore activating any child
CanMatchFnBefore the route is even matched
CanDeactivateFnBefore 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-sideWhat it actually does
Route guardStops a signed-out user seeing a blank page
Hidden buttonReduces clutter and confusion
Role checkShows relevant options
Token expiry checkAvoids 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 seeCauseFix
Token not attachedInterceptor not registeredAdd it to provideHttpClient(withInterceptors(...))
Infinite redirect loop to loginThe guard also protects the login routeExclude it
User stays "logged in" after expiryOnly checked the token exists, not expDecode and check
A blocked user still reaches the dataGuard hides the route, not the endpointThe API must enforce it
returnUrl lost after loginNot captured before redirectingStore 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 req instead of using req.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 a UrlTree
  • CanActivate on a lazy route where CanMatch would 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 disabled on the sign-in button
  • finalize missing, 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.

  1. Build AuthService with signals, token storage, expiry checking and try/catch.
  2. Build the login component with returnUrl handling.
  3. Sign in, then refresh the page. Confirm the session survives. Remove the constructor restore and confirm it does not.
  4. Write authInterceptor. Confirm the header in the Network tab.
  5. Modify req.headers directly instead of cloning. Confirm the header is absent.
  6. Remove the URL check and call a third-party API. Confirm your token was sent.
  7. Write errorInterceptor handling 401. Expire the token manually and confirm the redirect.
  8. Remove the login-endpoint exclusion and sign in with wrong credentials. Confirm the redirect loop.
  9. Write authGuard returning a UrlTree. Navigate to a protected route signed out.
  10. Change it to return false plus router.navigate(). Observe the racing navigations.
  11. Write roleGuard('Admin'). Sign in as a Teacher and confirm the redirect.
  12. Convert a lazy admin route to CanMatch. Confirm the chunk is not downloaded for a Teacher.
  13. 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.
  14. Edit the token payload in DevTools to change your role. Confirm the UI changes and the API still refuses.
  15. Add the unsaved-changes guard. Save successfully and navigate away — confirm no prompt.
  16. 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

  1. Why must an interceptor use req.clone()?
  2. Why exclude the login endpoint from 401 handling?
  3. Why return a UrlTree from a guard rather than calling navigate()?
  4. Why is a route guard not a security control?

Next: Guided Angular project