HTTP Interceptors — Auth Headers, Token Refresh, and Error Handling

Every HTTP request your Angular application makes to the Express API goes through the same pipeline: attach an Authorization header, handle 401 errors by refreshing the token, display a loading indicator, log errors, and retry on network failures. Writing this logic in every service method is repetitive and error-prone. Angular’s functional interceptors (introduced in Angular 15 alongside provideHttpClient) let you write this once and apply it to every outgoing request and incoming response automatically. This lesson builds a complete, production-ready interceptor stack for the MEAN Stack application.

Functional Interceptors vs Class-Based Interceptors

Aspect Functional (Angular 15+) Class-Based (Legacy)
Syntax Plain function: const myInterceptor: HttpInterceptorFn = (req, next) => { ... } Class implementing HttpInterceptor
DI access inject() directly inside the function Constructor injection
Registration provideHttpClient(withInterceptors([fn])) { provide: HTTP_INTERCEPTORS, useClass: Class, multi: true }
Ordering Executes in array order (first registered = first run) Same
Recommended Yes — modern approach Still works but not recommended for new code

Interceptor Execution Flow

Direction What Runs
Outgoing (request) Code before next(req) — modify request, add headers
Incoming (response) Code in pipe() after next(req) — handle response, catch errors
Error handling catchError() operator after next(req)
Retry retry() or retryWhen() operator
Note: Functional interceptors use Angular’s DI via inject() — but because the interceptor function is not itself a class managed by Angular’s injector, inject() works here because Angular calls the interceptor function within an injection context. Every time an HTTP request is made, Angular instantiates the injection context, calls your interceptor function, and inject() resolves services from the current injector. This means you can safely call inject(AuthStore), inject(Router), or any other service inside your functional interceptor.
Tip: The authentication interceptor should use HttpRequest.clone() to add headers rather than modifying the request directly. HTTP requests are immutable — Angular enforces this. req.clone({ setHeaders: { Authorization: 'Bearer ...' } }) creates a new request with the header added and all other properties preserved. Pass the cloned request to next(clonedReq), not the original next(req).
Warning: When implementing a token refresh interceptor, be careful about infinite loops: if the refresh token call itself receives a 401 (because the refresh token is also expired), you must not try to refresh again. Use a flag or check the request URL to detect that you are already on the refresh endpoint and redirect to login instead of retrying. Also, multiple simultaneous 401 errors should trigger only one refresh call — queue subsequent requests until the first refresh completes.

Complete Interceptor Stack

// ── Interceptor 1: Auth Header ────────────────────────────────────────────
// core/interceptors/auth.interceptor.ts

import { HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject }                         from '@angular/core';
import { catchError, switchMap }          from 'rxjs/operators';
import { throwError, BehaviorSubject, filter, take } from 'rxjs';
import { AuthStore }   from '../stores/auth.store';
import { AuthService } from '../services/auth.service';
import { Router }      from '@angular/router';

// Shared state to prevent concurrent refresh calls
let isRefreshing        = false;
const refreshTokenSubject$ = new BehaviorSubject<string | null>(null);

function addAuthHeader(req: HttpRequest<unknown>, token: string): HttpRequest<unknown> {
    return req.clone({
        setHeaders: { Authorization: `Bearer ${token}` },
    });
}

export const authInterceptor: HttpInterceptorFn = (req, next) => {
    const authStore    = inject(AuthStore);
    const authService  = inject(AuthService);
    const router       = inject(Router);

    const token = authStore.accessToken();

    // Skip auth header for the auth endpoints themselves
    if (req.url.includes('/auth/login') || req.url.includes('/auth/register')) {
        return next(req);
    }

    // Attach current access token
    const authReq = token ? addAuthHeader(req, token) : req;

    return next(authReq).pipe(
        catchError(error => {
            // 401 — try to refresh the token
            if (error.status === 401 && !req.url.includes('/auth/refresh')) {
                return handleTokenExpiry(req, next, authStore, authService, router);
            }
            return throwError(() => error);
        })
    );
};

function handleTokenExpiry(
    req: HttpRequest<unknown>,
    next: any,
    authStore: AuthStore,
    authService: AuthService,
    router: Router,
) {
    if (!isRefreshing) {
        isRefreshing = true;
        refreshTokenSubject$.next(null);

        return authService.refreshToken().pipe(
            switchMap(({ accessToken }) => {
                isRefreshing = false;
                refreshTokenSubject$.next(accessToken);
                authStore.setAccessToken(accessToken);
                return next(addAuthHeader(req, accessToken));
            }),
            catchError(err => {
                isRefreshing = false;
                authStore.logout();
                router.navigate(['/auth/login'], { queryParams: { reason: 'session_expired' } });
                return throwError(() => err);
            }),
        );
    }

    // If refresh already in progress, wait for it then retry
    return refreshTokenSubject$.pipe(
        filter(token => token !== null),
        take(1),
        switchMap(token => next(addAuthHeader(req, token!))),
    );
}

// ── Interceptor 2: Global Error Handler ──────────────────────────────────
// core/interceptors/error.interceptor.ts

import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject }  from '@angular/core';
import { catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { ToastService } from '../services/toast.service';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
    const toast = inject(ToastService);

    return next(req).pipe(
        catchError((error: HttpErrorResponse) => {
            // Do not show toast for auth endpoints — auth components handle their own errors
            const isAuthEndpoint = req.url.includes('/auth/');

            if (!isAuthEndpoint) {
                const message = error.error?.message ?? getDefaultMessage(error.status);

                switch (error.status) {
                    case 400: toast.error(message, 'Validation Error'); break;
                    case 403: toast.error('You do not have permission for this action'); break;
                    case 404: /* 404s often handled by components — skip global toast */ break;
                    case 409: toast.error(message, 'Conflict'); break;
                    case 429: toast.warning('Too many requests. Please slow down.'); break;
                    case 500: toast.error('Server error. Please try again.'); break;
                    case 0:   toast.error('Network error. Check your connection.'); break;
                }
            }

            return throwError(() => error);
        })
    );
};

function getDefaultMessage(status: number): string {
    const messages: Record<number, string> = {
        400: 'Invalid request data',
        401: 'Authentication required',
        403: 'Access denied',
        404: 'Not found',
        500: 'Server error',
        0:   'Network error',
    };
    return messages[status] ?? 'An unexpected error occurred';
}

// ── Interceptor 3: Loading Indicator ──────────────────────────────────────
// core/interceptors/loading.interceptor.ts

import { HttpInterceptorFn } from '@angular/common/http';
import { inject }            from '@angular/core';
import { finalize }          from 'rxjs/operators';
import { LoadingService }    from '../services/loading.service';

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
    const loading = inject(LoadingService);

    // Skip loading for background/silent requests
    if (req.headers.has('X-Skip-Loading')) {
        const cleanReq = req.clone({ headers: req.headers.delete('X-Skip-Loading') });
        return next(cleanReq);
    }

    loading.show();

    return next(req).pipe(
        finalize(() => loading.hide())
    );
};

// core/services/loading.service.ts
@Injectable({ providedIn: 'root' })
export class LoadingService {
    private _activeRequests = signal(0);
    readonly isLoading = computed(() => this._activeRequests() > 0);

    show(): void { this._activeRequests.update(n => n + 1); }
    hide(): void { this._activeRequests.update(n => Math.max(0, n - 1)); }
}

// ── Register all interceptors ─────────────────────────────────────────────
// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor }    from './core/interceptors/auth.interceptor';
import { errorInterceptor }   from './core/interceptors/error.interceptor';
import { loadingInterceptor } from './core/interceptors/loading.interceptor';

export const appConfig: ApplicationConfig = {
    providers: [
        // Order matters: loading → auth → error
        provideHttpClient(
            withInterceptors([loadingInterceptor, authInterceptor, errorInterceptor])
        ),
    ],
};

How It Works

Step 1 — Interceptors Form an Ordered Chain

Interceptors are registered in an array and execute in order: the first interceptor processes the outgoing request, then passes to the next, which passes to the next, until HttpBackend makes the actual HTTP call. The response then travels back through the chain in reverse order. This means the first-registered interceptor is the outermost wrapper — it sees the request before any other interceptor and sees the response after all others have processed it. Order matters: the loading interceptor starts counting before auth adds headers.

Step 2 — req.clone() Creates a Modified Copy of the Request

HTTP requests are immutable once created. To add headers, change the URL, or modify the body, you must create a new request with req.clone({ ...changes }). The clone copies all existing properties and applies your changes. setHeaders adds headers while preserving existing ones. headers.delete('X-Skip-Loading') removes a header. The cloned request is passed to next(clonedReq) for the next interceptor in the chain.

Step 3 — catchError Intercepts Error Responses

By piping catchError() onto next(req), the interceptor sees every HTTP error response. The error handler can return throwError(() => error) to propagate the error normally, return a fallback value, or — for token refresh — return a new Observable that makes the original request again with a fresh token. Multiple interceptors can each have their own catchError — they execute in reverse registration order.

Step 4 — The Token Refresh Pattern Prevents Duplicate Refresh Calls

When a 401 arrives and a refresh is needed, multiple requests might arrive simultaneously (if several API calls were made before the 401 was processed). The isRefreshing flag and refreshTokenSubject$ BehaviorSubject coordinate concurrent 401 handling: the first 401 triggers the refresh; subsequent 401s wait on refreshTokenSubject$ (which emits null until the refresh completes). When the refresh succeeds, all waiting requests retry with the new token simultaneously.

Step 5 — finalize() Runs on Both Success and Error

The loading interceptor uses finalize(() => loading.hide()) to decrement the active request counter whether the request succeeds or fails. Without finalize, a failed request would never call loading.hide(), leaving the loading indicator permanently visible. finalize is RxJS’s equivalent of a try-finally block — it always runs when the Observable completes or errors.

Common Mistakes

Mistake 1 — Modifying req directly instead of cloning

❌ Wrong — HTTP requests are immutable, this throws an error:

req.headers.set('Authorization', `Bearer ${token}`);  // Error: cannot modify immutable headers
return next(req);

✅ Correct — always clone the request:

const authReq = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
return next(authReq);

Mistake 2 — Not handling the token refresh loop (infinite 401 retries)

❌ Wrong — refresh endpoint also gets 401 → infinite loop:

catchError(err => {
    if (err.status === 401) return authService.refreshToken().pipe(switchMap(...));
    return throwError(() => err);
})
// If /auth/refresh returns 401, this tries to refresh again... forever

✅ Correct — exclude auth endpoints from retry logic:

if (error.status === 401 && !req.url.includes('/auth/refresh')) {
    return handleTokenExpiry(req, next, ...);
}
return throwError(() => error);

Mistake 3 — Not using finalize for loading state cleanup

❌ Wrong — failed request leaves loading indicator active forever:

loading.show();
return next(req).pipe(
    tap(() => loading.hide())  // only called on success, not on error!
);

✅ Correct — use finalize which runs on both success and error:

loading.show();
return next(req).pipe(finalize(() => loading.hide()));

Quick Reference

Task Code
Define interceptor export const myInterceptor: HttpInterceptorFn = (req, next) => { ... }
Register interceptors provideHttpClient(withInterceptors([a, b, c]))
Add request header next(req.clone({ setHeaders: { Authorization: 'Bearer ...' } }))
Remove request header req.clone({ headers: req.headers.delete('X-Header') })
Handle error next(req).pipe(catchError(err => throwError(() => err)))
Always cleanup next(req).pipe(finalize(() => cleanup()))
Inject service const service = inject(MyService) inside interceptor fn
Skip interceptor Check req.headers.has('X-Skip') and pass unmodified

🧠 Test Yourself

An auth interceptor catches a 401 error and calls authService.refreshToken(), then retries the original request with the new token. Three simultaneous requests all return 401. How many refresh calls should be made?