HTTP Interceptors
Understand how Angular HTTP interceptors process outgoing requests and incoming responses for authentication, logging, error handling, retries, loading indicators, and shared HTTP behavior.
Lesson content
What is an HTTP Interceptor? An HTTP interceptor is middleware that runs between Angular's HttpClient and the backend server. It can inspect or modify outgoing requests and incoming responses from one central location. Simple Definition HTTP Interceptor = Request Middleware + Response Middleware Common Uses Add authentication tokens Add common HTTP headers Handle API errors Log requests and responses Retry failed requests Display loading indicators Measure request duration Cache selected responses HTTP Interceptor Flow Component or Service ↓ HttpClient Request ↓ Interceptor 1 ↓ Interceptor 2 ↓ Backend Server ↓ Interceptor 2 ↓ Interceptor 1 ↓ Component or Service Requests move through interceptors in registration order. Responses move back through the chain in reverse order. Functional Interceptors Modern Angular recommends functional interceptors because their configuration and ordering are more predictable. Basic Interceptor import { HttpInterceptorFn } from '@angular/common/http'; export const loggingInterceptor: HttpInterceptorFn = ( request, next ) => { console.log( 'Request URL:', request.url ); return next(request); }; Important Parts Part Purpose request The outgoing HTTP request next Passes the request to the next interceptor or backend next(request) Returns the HTTP event Observable Registering Functional Interceptors import { ApplicationConfig } from '@angular/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; export const appConfig: ApplicationConfig = { providers: [ provideHttpClient( withInterceptors([ authInterceptor, loggingInterceptor, errorInterceptor ]) ) ] }; Registration Order Request: authInterceptor ↓ loggingInterceptor ↓ errorInterceptor ↓ Backend Response: Backend ↓ errorInterceptor ↓ loggingInterceptor ↓ authInterceptor Authentication Interceptor An authentication interceptor can add a bearer token to outgoing API requests. import { HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; export const authInterceptor: HttpInterceptorFn = ( request, next ) => { const authService = inject(AuthService); const token = authService.getToken(); if (!token) { return next(request); } const authenticatedRequest = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next( authenticatedRequest ); }; Authentication Flow HttpClient Request ↓ Read Authentication Token ↓ Token Available? ┌──────┴──────┐ │ │ Yes No │ │ ▼ ▼ Clone Request Original Request Add Header │ │ │ └───────┬────────┘ ↓ Send Request Why Must Requests Be Cloned? Angular HTTP request objects are immutable. Their headers, URL, parameters, and body-related options should not be modified directly. Wrong: request.headers.set( 'Authorization', token ); Correct: const modifiedRequest = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); Adding Multiple Headers export const headerInterceptor: HttpInterceptorFn = ( request, next ) => { const modifiedRequest = request.clone({ setHeaders: { 'X-App-Version': '1.0.0', 'X-Client-Type': 'web' } }); return next( modifiedRequest ); }; Skipping External URLs Authentication headers should normally be added only to trusted application APIs. export const authInterceptor: HttpInterceptorFn = ( request, next ) => { const apiUrl = 'https://api.example.com'; if ( !request.url.startsWith( apiUrl ) ) { return next(request); } const token = inject(AuthService) .getToken(); if (!token) { return next(request); } return next( request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) ); }; Do not send private authentication tokens to unknown or third-party domains. Response Logging Interceptor The Observable returned by next() contains HTTP events. import { HttpEventType, HttpInterceptorFn } from '@angular/common/http'; import { tap } from 'rxjs'; export const loggingInterceptor: HttpInterceptorFn = ( request, next ) => { const startedAt = performance.now(); return next(request).pipe( tap(event => { if ( event.type === HttpEventType.Response ) { const duration = performance.now() - startedAt; console.log( request.method, request.url, event.status, `${Math.round( duration )} ms` ); } }) ); }; Error Handling Interceptor import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; import { catchError, throwError } from 'rxjs'; export const errorInterceptor: HttpInterceptorFn = ( request, next ) => { return next(request).pipe( catchError( ( error: HttpErrorResponse ) => { if ( error.status === 401 ) { console.error( 'Unauthorized request' ); } if ( error.status === 403 ) { console.error( 'Access denied' ); } if ( error.status >= 500 ) { console.error( 'Server error' ); } return throwError( () => error ); } ) ); }; Common HTTP Status Codes Status Meaning 400 Bad request 401 Authentication required or invalid 403 User is authenticated but not authorized 404 Resource not found 500 Server error Global Error Message Example export const errorInterceptor: HttpInterceptorFn = ( request, next ) => { const notificationService = inject( NotificationService ); return next(request).pipe( catchError( ( error: HttpErrorResponse ) => { const message = error.error?.message ?? 'Something went wrong.'; notificationService .showError(message); return throwError( () => error ); } ) ); }; The interceptor can show a general error message, while the calling component can still handle feature-specific behavior. Retry Interceptor Selected temporary failures can be retried. import { HttpInterceptorFn } from '@angular/common/http'; import { retry } from 'rxjs'; export const retryInterceptor: HttpInterceptorFn = ( request, next ) => { return next(request).pipe( retry({ count: 2, delay: 1000 }) ); }; Important Warning Do not automatically retry every request. Retrying mutation requests such as POST , PUT , PATCH , or DELETE may repeat an operation. Retry Only GET Requests export const retryInterceptor: HttpInterceptorFn = ( request, next ) => { if ( request.method !== 'GET' ) { return next(request); } return next(request).pipe( retry({ count: 2, delay: 1000 }) ); }; Loading Indicator Interceptor import { HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { finalize } from 'rxjs'; export const loadingInterceptor: HttpInterceptorFn = ( request, next ) => { const loadingService = inject(LoadingService); loadingService.show(); return next(request).pipe( finalize(() => { loadingService.hide(); }) ); }; Why Use finalize()? The finalize() callback runs when the request completes, fails, or is cancelled. Request Starts ↓ Show Loader ↓ Success, Error, or Cancellation ↓ finalize() ↓ Hide Loader Handling Concurrent Requests A loading service should count active requests instead of storing only a simple boolean. import { Injectable, computed, signal } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class LoadingService { private activeRequests = signal(0); readonly isLoading = computed( () => this.activeRequests() > 0 ); show() { this.activeRequests.update( count => count + 1 ); } hide() { this.activeRequests.update( count => Math.max( count - 1, 0 ) ); } } Refresh Token Interceptor An interceptor can attempt to refresh an expired access token after a 401 response. export const refreshTokenInterceptor: HttpInterceptorFn = ( request, next ) => { const authService = inject(AuthService); return next(request).pipe( catchError(error => { if ( error.status !== 401 || request.url.includes( '/auth/refresh' ) ) { return throwError( () => error ); } return authService .refreshToken() .pipe( switchMap( token => { const retryRequest = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next( retryRequest ); } ) ); }) ); }; Refresh Flow API Request ↓ 401 Response ↓ Refresh Token Request ↓ New Access Token ↓ Clone Original Request ↓ Retry Original Request Real applications must also prevent multiple simultaneous refresh requests from creating multiple refresh-token calls. Request Context HttpContext allows a request to provide metadata to interceptors without sending that metadata to the backend. Create a Context Token import { HttpContextToken } from '@angular/common/http'; export const SKIP_LOADING = new HttpContextToken<boolean>( () => false ); Read It in the Interceptor export const loadingInterceptor: HttpInterceptorFn = ( request, next ) => { if ( request.context.get( SKIP_LOADING ) ) { return next(request); } const loadingService = inject(LoadingService); loadingService.show(); return next(request).pipe( finalize(() => loadingService.hide() ) ); }; Set It on a Request import { HttpContext } from '@angular/common/http'; this.http.get( '/api/background-status', { context: new HttpContext() .set( SKIP_LOADING, true ) } ); Skipping Authentication with HttpContext export const SKIP_AUTH = new HttpContextToken<boolean>( () => false ); export const authInterceptor: HttpInterceptorFn = ( request, next ) => { if ( request.context.get( SKIP_AUTH ) ) { return next(request); } const token = inject(AuthService) .getToken(); return next( token ? request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : request ); }; Short-Circuiting a Request An interceptor does not always have to call next() . For example, a caching interceptor may return a stored response directly. import { HttpResponse } from '@angular/common/http'; import { of } from 'rxjs'; export const cacheInterceptor: HttpInterceptorFn = ( request, next ) => { if ( request.method !== 'GET' ) { return next(request); } const cachedResponse = cache.get(request.url); if (cachedResponse) { return of( new HttpResponse({ body: cachedResponse.body, status: 200 }) ); } return next(request); }; When an interceptor does not call next() , later interceptors and the backend are skipped. Class-Based Interceptors Existing Angular applications may use class-based dependency-injection interceptors. import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @Injectable() export class LoggingInterceptor implements HttpInterceptor { intercept( request: HttpRequest<unknown>, next: HttpHandler ): Observable< HttpEvent<unknown> > { console.log( request.url ); return next.handle( request ); } } Registering Class-Based Interceptors import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; export const appConfig: ApplicationConfig = { providers: [ provideHttpClient( withInterceptorsFromDi() ), { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true } ] }; The multi: true setting allows multiple class-based interceptors to be registered. Functional vs Class-Based Interceptors Functional Interceptor Class-Based Interceptor Uses HttpInterceptorFn Implements HttpInterceptor Registered with withInterceptors() Registered through HTTP_INTERCEPTORS Uses next(request) Uses next.handle(request) Uses inject() for dependencies Uses constructor injection or inject() Predictable registration order Order can be harder to predict in complex injector hierarchies Recommended for new code Common in existing applications Interceptor Ordering Example provideHttpClient( withInterceptors([ authInterceptor, loadingInterceptor, loggingInterceptor, errorInterceptor ]) ); Outgoing Request Auth ↓ Loading ↓ Logging ↓ Error ↓ Backend Incoming Response Backend ↓ Error ↓ Logging ↓ Loading ↓ Auth Interceptor order matters because one interceptor can depend on modifications made by another. Interceptor Dependency Injection Functional interceptors run inside an Angular injection context. export const languageInterceptor: HttpInterceptorFn = ( request, next ) => { const languageService = inject(LanguageService); const language = languageService .currentLanguage(); return next( request.clone({ setHeaders: { 'Accept-Language': language } }) ); }; Interceptors Only Affect HttpClient Angular interceptors process requests made through Angular's configured HttpClient . They do not automatically intercept requests made directly with: fetch() XMLHttpRequest Third-party clients that do not use Angular HttpClient this.http.get('/api/users'); The request above passes through Angular interceptors. fetch('/api/users'); The direct fetch() request does not pass through Angular interceptors. Interceptors Are Not Backend Security An authentication interceptor only attaches credentials to a request. The backend must still validate: The access token User identity Roles and permissions Request ownership Input data Frontend Interceptor = Attach Token Backend = Validate Token + Enforce Authorization Common Mistakes 1. Modifying the Request Directly Avoid: request.url = '/api/changed'; Clone the request instead. 2. Forgetting to Call next() Wrong: export const interceptor: HttpInterceptorFn = ( request, next ) => { console.log( request.url ); }; Correct: return next(request); 3. Sending Tokens to Every Domain Add authentication headers only to trusted API URLs. 4. Swallowing Errors Avoid: catchError(() => EMPTY ) Unless intentional, return the error so the caller can handle it. return throwError( () => error ); 5. Retrying Unsafe Requests Automatically Automatic retries can repeat database or payment operations. 6. Using a Boolean Loader for Concurrent Requests Use an active-request counter so one completed request does not hide the loader while another request is still running. 7. Creating Multiple Refresh Requests Coordinate token refresh so concurrent 401 responses share one refresh operation. 8. Doing Feature-Specific Logic Globally Keep interceptors focused on behavior shared across many requests. 9. Assuming Interceptors Process fetch() Only requests made through the configured Angular HttpClient use the interceptor chain. 10. Using Class Interceptors Automatically in New Code Prefer functional interceptors and withInterceptors() for new implementations. Best Practices Prefer functional interceptors for new Angular code Keep each interceptor focused on one responsibility Clone requests before modifying them Register interceptors in a deliberate order Add tokens only to trusted API domains Use HttpContext to skip selected behavior Use finalize() for loader cleanup Retry only safe and temporary failures Return errors unless they are intentionally handled Prevent duplicate refresh-token requests Never treat frontend interceptors as backend authorization Avoid logging credentials and sensitive request data Interview Questions What is an HTTP interceptor? An HTTP interceptor is middleware that can inspect, modify, handle, or replace requests and responses made through Angular's HttpClient . What are common interceptor use cases? Common uses include authentication headers, logging, error handling, retries, caching, request timing, and loading indicators. What type is used for a functional interceptor? Functional interceptors use the HttpInterceptorFn type. How are functional interceptors registered? provideHttpClient( withInterceptors([ authInterceptor ]) ) Why must an HTTP request be cloned? Angular HTTP requests are immutable, so modifications must be applied to a cloned request. How can a service be used inside a functional interceptor? Use Angular's inject() function because functional interceptors run inside an injection context. What happens when next() is not called? The interceptor handles the request itself, and later interceptors and the backend are skipped. What is HttpContext used for? It passes request-specific metadata to interceptors without sending that metadata to the backend. What is the difference between 401 and 403? A 401 response generally means authentication is missing or invalid. A 403 response means access is forbidden despite the current authentication state. Do interceptors work with fetch()? No. Angular interceptors apply to requests made through the configured Angular HttpClient . Which interceptor style is recommended? Functional interceptors configured through withInterceptors() are recommended for new Angular code. Key Point HTTP interceptors centralize behavior shared by Angular HTTP requests and responses without repeating that logic inside every service. Easy Formula HTTP Interceptor = Request Middleware + Response Middleware Functional Interceptor = HttpInterceptorFn + next(request) Modify Request = request.clone() Register = provideHttpClient() + withInterceptors() Class Interceptor = HttpInterceptor + HTTP_INTERCEPTORS + multi: true HttpClient ↓ Request Interceptors ↓ Backend ↓ Response Interceptors ↓ Component or Service