Angular HTTP Interceptors: Auth Tokens, Errors and Loading State

Angular Tutorial: Beginner to Advanced · 15. Architecture, configuration and errors · 4 min read · Updated

Almost every request needs the same extras: an auth header, error handling, a loading bar. Writing that in every service would be repetitive and easy to forget. An interceptor is a function that sees every request and response.

What you will learn

  • How to write a functional interceptor
  • How to add an auth header and handle errors in one place
  • How to drive a global loading indicator

A loading counter service

// src/app/http/loading.ts
import { Injectable, computed, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class Loading {
  private readonly pending = signal(0);
  readonly active = computed(() => this.pending() > 0);

  start(): void {
    this.pending.update((n) => n + 1);
  }

  stop(): void {
    this.pending.update((n) => Math.max(0, n - 1));
  }
}

A counter, not a boolean, because several requests can run at the same time.

The interceptors

// src/app/http/interceptors.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, finalize, throwError } from 'rxjs';
import { Auth } from '../auth/auth';
import { Loading } from './loading';

// 1. Add the token to requests that go to our API
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(Auth);
  const name = auth.userName();
  if (name && req.url.startsWith('https://api.example.com')) {
    // Demo only. A real app sends a token received from the server.
    req = req.clone({ setHeaders: { Authorization: `Bearer demo-token-for-${name}` } });
  }
  return next(req);
};

// 2. Turn the global loading flag on and off
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  const loading = inject(Loading);
  loading.start();
  return next(req).pipe(finalize(() => loading.stop()));
};

// 3. Handle errors in one place
export const errorInterceptor: HttpInterceptorFn = (req, next) =>
  next(req).pipe(
    catchError((error: unknown) => {
      if (error instanceof HttpErrorResponse) {
        if (error.status === 401) {
          console.warn('Session expired. Redirect to login here.');
        } else if (error.status >= 500) {
          console.error('Server error', error.status, req.url);
        }
      }
      // Re-throw so the caller can still show its own message.
      return throwError(() => error);
    }),
  );

Requests are immutable. To change one, call req.clone({...}) and pass the clone to next. finalize runs on success, error and cancel, so the loading counter always goes back down.

Register them

// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { APP_SETTINGS } from './di/app-config.token';
import { authInterceptor, errorInterceptor, loadingInterceptor } from './http/interceptors';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(
      withFetch(),
      // Order matters: the first interceptor runs first on the way out.
      withInterceptors([loadingInterceptor, authInterceptor, errorInterceptor]),
    ),
    { provide: APP_SETTINGS, useValue: { apiUrl: 'https://api.example.com', featureBeta: true } },
  ],
};

Show the global loading bar

// src/app/app.ts
import { Component, inject } from '@angular/core';
import { Loading } from './http/loading';

@Component({
  imports: [],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {
  protected readonly loading = inject(Loading);
}
<!-- src/app/app.html -->
@if (loading.active()) {
  <div class="bar" role="progressbar" aria-label="Loading"></div>
}
<main>
  <h1>Interceptors</h1>
</main>
/* src/app/app.css */
.bar { position: fixed; top: 0; left: 0; right: 0; height: 4px; background: #0b6cff; }

The key parts

  • A functional interceptor has the signature (req, next) => Observable. inject() works inside it.
  • Never mutate req. Always clone.
  • Add the token only to your own API, never to third-party URLs, or you may leak it.
  • Interceptors also apply to httpResource, because it uses HttpClient.
  • Keep interceptors small and single-purpose. Order them deliberately.
  • Real token storage and refresh flows are security-sensitive, so see the security lesson.

Try it yourself

  1. Add a retry interceptor that retries GET requests once, but never POST.
  2. Add a X-Request-Id header with a random id to every request.
  3. Show a toast message for status === 0 (network down) in the error interceptor.

Quick check

  1. Why must you clone a request instead of changing it?
  2. Why use finalize for the loading counter?
  3. Why only add the token to your own API URLs?

Next, organise bigger apps with a clear feature architecture. Read more in the interceptors guide.

Frequently asked questions

Do interceptors run for requests made with fetch directly?

No. Only HttpClient (and httpResource, which uses it) goes through interceptors.

Can I skip an interceptor for one request?

Yes, use HttpContext with a custom token and check it inside the interceptor.