Introduction
Angular interceptors are a well-known feature, yet most projects only use them for basic concerns such as authentication or authorization. In practice, there are numerous other situations where interceptors can prove extremely valuable. This article examines several practical and frequent scenarios where interceptors can simplify your codebase.
Understanding interceptors
To begin, let us clarify what an interceptor actually is. In modern Angular, an interceptor is a function that relies on the "Chain of Responsibility" design pattern, enabling us to inject custom logic into the HTTP request/response lifecycle. In essence, we can intercept an outgoing request, modify it, and forward it along the chain to subsequent interceptors. The same applies to the incoming response, which we can also process before it reaches the calling code — a capability we will leverage later.
With that foundation, let’s walk through several interceptor implementations that can make working with HTTP significantly cleaner.
A straightforward authentication example
Let’s address the most common use case right away: nearly every developer has created an AuthInterceptor at some point. Below is a minimal version:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
if (token) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
}
});
}
return next(req);
};
Here, we simply retrieve the token from AuthService and attach it to the request headers. Straightforward, isn’t it? Nevertheless, caution is advised. In his article, Tim Deschryver highlights a security concern with this pattern: if your application also makes calls to third-party APIs, those requests would carry your token as well, potentially exposing sensitive user information. A safer approach involves checking the destination URL before adding the token:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const uri = new URL(req.url);
if (uri.hostname !== 'trusted-domain.com') {
return next(req);
}
const authService = inject(AuthService);
const token = authService.getToken();
if (token) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
}
});
}
return next(req);
};
With authentication handled, what other possibilities exist?
Modifying request URLs
In larger applications, it’s typical to have multiple environments, each with its own API endpoint. This information is often stored in environment files, so let’s assume we have an Environment injectable that exposes the current environment and its API base URL. However, even with that setup, developers still need to prepend the full URL to every HTTP call:
@Injectable({providedIn: 'root'})
export class DataService {
private readonly http = inject(HttpClient);
private readonly environment = inject(Environment);
getData() {
return this.http.get(this.environment.apiUrl + '/data');
}
getDataById(id: number) {
return this.http.get(this.environment.apiUrl + `/data/${id}`);
}
}
As shown, every request must include the complete URL, which is slightly tedious but also prone to minor errors. Moreover, if the logic for determining the API URL ever changes, you might need to update dozens or hundreds of service methods. An interceptor solves this neatly:
export const apiUrlInterceptor: HttpInterceptorFn = (req, next) => {
const environment = inject(Environment);
const baseUrl = environment.getAPIUrl();
req = req.clone({
url: `${baseUrl}/${req.url}`,
});
return next(req);
};
Now, services can make calls using just a relative path:
@Injectable({providedIn: 'root'})
export class DataService {
private readonly http = inject(HttpClient);
getData() {
return this.http.get('/data');
}
getDataById(id: number) {
return this.http.get(`/data/${id}`);
}
}
Next, let’s explore how interceptors can interact with application state and the component lifecycle.
Interceptors and application state
Consider a scenario where we want to show a small loading indicator at the top of the page whenever an HTTP request is active. To illustrate the flexibility of interceptors, let’s also assume the app uses a state management library such as NgRx, and we have an action named setLoading that toggles the loading flag. Since interceptors support dependency injection, we can directly dispatch this action from within an interceptor to control the UI:
export const loaderInterceptor: HttpInterceptorFn = (req, next) => {
const store = inject(Store);
store.dispatch(setLoading(true));
return next(req).pipe(
tap((res) => {
if (res.type === HttpEventType.Response) {
store.dispatch(setLoading(false));
}
})
);
};
Notice how this interceptor primarily operates on the response rather than the request. This pattern will recur in several examples ahead.
With this setup, your service methods remain unchanged, and components simply read the data from the store without ever knowing how the loading state was set to true or false.
But what if we don’t want the loading bar to appear on every request? Some requests, such as background logging or third-party initialization, might better proceed silently. How can we handle that?
Adding custom context to requests
Angular provides a mechanism for attaching custom metadata to HTTP requests via the HttpRequestContextToken class. This allows us to define custom flags or data for specific requests, which interceptors can then inspect and react to.
Here’s how we can put this to use:
export const NoLoaderToken = new HttpContextToken<boolean>(() => false);
@Injectable({providedIn: 'root'})
export class LoggerService {
log(data: any) {
return this.http.post('log', {context: NoLoaderToken, body: data});
}
}
In this snippet, we create a NoLoaderToken using HttpContextToken, which serves as a marker to bypass the loader. We then update our loader interceptor accordingly:
export const loaderInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(NoLoaderToken)) {
return next(req);
}
const store = inject(Store);
store.dispatch(setLoading(true));
return next(req).pipe(
tap((res) => {
if (res.type === HttpEventType.Response) {
store.dispatch(setLoading(false));
}
})
);
}
};
Now we can explicitly distinguish between requests that should trigger the loader and those that should remain quiet.
Let’s now look at how interceptors can help bridge disagreements between front-end and back-end teams.
Throwing errors on implicit failures
Some APIs — increasingly common these days — avoid HTTP error codes and instead return a JSON object with a boolean field indicating success or failure. A typical response might look like:
{
"success": true,
"data": {
"id": 1,
"name": "John Doe"
},
"error": null
}
Whether this design is ideal is debatable, but it can cause friction when front-end and back-end developers have differing expectations. It also creates repetitive error checks within services, since you might need to verify the boolean manually:
@Injectable({providedIn: 'root'})
export class DataService {
private readonly http = inject(HttpClient);
getData() {
return this.http.get('/data').pipe(
map(res => {
if (res.success) {
return res.data;
}
// handle error in some way
}),
catchError((err) => {
// notice that even with this approach,
// we still have to write `catchError`,
// as errors can arise not only from the backend
// but also from network connection
// bugs in our won code and so on
})
);
}
}
How can we solve this without duplicating similar checks throughout the codebase? Once again, response interceptors come to the rescue:
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
map(res => {
if (res.type === HttpEventType.Response) {
const body = res.body as {success: boolean, message?: string};
if (body.success === false) {
throw new HttpErrorResponse({error: body.message});
}
return res;
}
return res;
}),
);
}
Here, we inject logic that checks for a {success: false} response and throws a proper error. Consequently, services can simply rely on the catchError operator — no manual double-checks needed.
@Injectable({providedIn: 'root'})
export class DataService {
private readonly http = inject(HttpClient);
getData() {
return this.http.get('/data').pipe(
map(res => res.data),
catchError((err) => {
// handle error in some way
})
);
}
}
Next, let’s examine body transformation.
Transforming the response body
Returning to the previous example, because the backend wraps data in an envelope, services frequently need map(res => res.data) to extract the actual payload. With an interceptor, we can strip away the wrapper before the response reaches the service:
export const responseUnwrapInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
map(res => {
if (res.type === HttpEventType.Response) {
const body = res.body as {success: boolean, data?: any, message?: string};
if (body.success === true) {
return res.clone({body: body.data});
}
return res;
}
return res;
}),
);
}
That’s all there is to it — now components (or NgRx Effects) can directly consume the data or handle errors with catchError, without worrying about the backend’s response format.
Important considerations
As we’ve seen, interceptors are powerful, but since they affect the whole application, they must be used judiciously. The first example already showed how a naive implementation can introduce security vulnerabilities, while the HttpContextToken class helped us avoid other potential pitfalls.
Another crucial point is ordering: interceptors execute in the same sequence in which they are registered in the application config. A logical order is essential. For instance, our authInterceptor relies on the hostname for security checks, so it must run after the apiUrlInterceptor to function correctly. Then comes the loaderInterceptor. Response-focused interceptors should ideally run after request-focused ones; in our case, the errorInterceptor should come before the responseUnwrapInterceptor, since we want to catch errors prior to attempting to unwrap the response.
An ideal configuration might look like this:
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor,
apiUrlInterceptor,
loaderInterceptor,
errorInterceptor,
responseUnwrapInterceptor,
])
),
],
};
Thus, keeping these ordering concerns in mind is vital.
Conclusion
Interceptors have great potential and, frankly, are underused. They can significantly simplify recurring HTTP-related tasks and clean up a lot of repetitive code. There are many avenues we haven’t explored here — caching, logging, and others — but some of those are covered in the official documentation and are easy to implement. Hopefully, this overview gives you a clearer picture of what interceptors can do and encourages you to put them to broader use in your projects.
A quick note

You may have noticed that this article only uses function-based interceptors. Given the rapid changes in Angular, many developers are uncertain about which approach to adopt, how to apply it, or how to migrate older code. Fortunately, I have something to help with that: my first book is about to be published!
Entitled "Modern Angular," it’s a thorough guide to the standout features introduced in recent versions (v14–v18), including standalone components, improved inputs, signals (of course!), better RxJS interoperability, SSR, and much more. If that sounds useful, you can check it out here. The manuscript is now in copy-editing, with a print release imminent. In the meantime, it’s available in Early Access, with all 10 chapters already accessible online. To stay informed about the print release, feel free to follow me on Twitter or LinkedIn, where I’ll post updates and any promotional offers.

