⚙️ How to Handle HTTP Interceptors in Angular 20 — Clean, Modular, and Circular-Safe
Angular 20 introduced a modernized functional interceptor API (HttpInterceptorFn) and provideHttpClient() pattern for registering interceptors in the application configuration.
This makes it easier to build lightweight, tree-shakable interceptors without traditional class-based boilerplate — and avoid the dependency nightmares we all faced before (👀 looking at you, circular dependencies).
In this post, we’ll go through:
- Setting up interceptors with the new Angular 20 style
- Building a prototype-ready
authInterceptorthat adds headers - Implementing a universal
errorInterceptorwith token refresh logic - Using a dedicated
AuthResetservice to handle token refresh cleanly
🧩 The New Way: Registering Interceptors via provideHttpClient
In Angular 20, interceptors are registered inside the app.config.ts (or wherever you define ApplicationConfig), not in a module or provider array.
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor])
),
],
};That’s it — Angular will now automatically apply these interceptors to every HttpClient call in your app.
🔐 Building a Prototypable Auth Interceptor
Your Auth Interceptor is responsible for:
- Adding authentication and localization headers
- Skipping certain requests (e.g., translations or login)
- Optionally bypassing itself for special cases
Here’s a clean, project-agnostic version:
import { HttpInterceptorFn, HttpHeaders } from '@angular/common/http';
import Cookies from 'js-cookie';
export const SKIP_AUTH_INTERCEPTOR = new HttpContextToken(() => false);
export const authInterceptor: HttpInterceptorFn = (request, next) => {
// Skip via context flag
if (request.context.get(SKIP_AUTH_INTERCEPTOR)) {
return next(request);
}
// Skip if marked with custom header
if (request.headers.has('skipHeader')) {
const headers = request.headers.delete('skipHeader');
request = request.clone({ headers });
return next(request);
}
const token = Cookies.get('sessId') || '';
const isAuthRequest = request.url.includes('/login');
const isStaticAsset = request.url.includes('/assets/');
const newHeaders: Record<string, string> = {
'Accept-Language': localStorage.getItem('pref_lang') || 'en',
};
if (!isAuthRequest && !isStaticAsset && token) {
newHeaders['Authorization'] = token;
}
// Add JSON content type for relevant methods
if (
!request.headers.has('Content-Type') &&
['POST', 'PUT', 'PATCH'].includes(request.method)
) {
newHeaders['Content-Type'] = 'application/json';
}
const modifiedReq = request.clone({ setHeaders: newHeaders });
return next(modifiedReq);
};💡 Highlights
- Uses
HttpContextTokenfor opt-out requests (cleaner than URL checks) - Automatically adds headers but avoids over-specifying (keeps it flexible)
- Minimal assumptions — perfect for dropping into any Angular app
🚨 Global Error Interceptor
Next, the Error Interceptor.
Its role is to catch backend errors globally and decide what to do:
- Retry requests if the token expired (401)
- Show UI messages for other status codes
import { HttpInterceptorFn } from '@angular/common/http';
import { catchError } from 'rxjs/operators';
import { inject } from '@angular/core';
import { HttpErrorHandler } from '../services/http-error-handler.service';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_AUTH_INTERCEPTOR)) {
return next(req);
}
const errorHandler = inject(HttpErrorHandler);
return next(req).pipe(
catchError((err) => errorHandler.handle(err, req, next))
);
};This interceptor delegates logic to a separate service, HttpErrorHandler, so that interceptors stay focused and maintainable.
🧰 The HttpErrorHandler Service
Here’s a clean, reusable error handler implementation:
import { Injectable, inject } from '@angular/core';
import { MessageService } from 'primeng/api';
import { AuthReset } from './auth-reset.service';
import { catchError, switchMap, throwError } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class HttpErrorHandler {
private messageService = inject(MessageService);
private authReset = inject(AuthReset);
private readonly LOGIN_URL = '/login';
handle(error: any, req: any, next: any) {
switch (error.status) {
case 401:
if (req.url.includes(this.LOGIN_URL)) break;
return this.handle401(req, next);
case 403:
this.messageService.add({ severity: 'error', summary: 'Access Denied', detail: 'Not allowed.' });
break;
case 404:
this.messageService.add({ severity: 'error', summary: 'Not Found', detail: 'Resource not found.' });
break;
case 500:
this.messageService.add({ severity: 'error', summary: 'Server Error', detail: 'Try again later.' });
break;
default:
this.messageService.add({ severity: 'error', summary: 'Error', detail: error.message });
}
return throwError(() => error);
}
private handle401(req: any, next: any) {
return this.authReset.setNewRefreshToken().pipe(
switchMap((newToken) => {
const newReq = req.clone({
setHeaders: { Authorization: newToken ?? '' },
});
return next.handle(newReq);
}),
catchError((err) => {
this.authReset.logout(req.url);
return throwError(() => err);
})
);
}
}🔄 The AuthReset Service: Refreshing Tokens Without Loops
This service (same as in your previous post) handles token refresh safely using a dedicated HttpBackend instance to avoid circular dependencies.
import { Injectable, Injector, inject } from '@angular/core';
import { HttpBackend, HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, map, take, tap } from 'rxjs/operators';
import Cookies from 'js-cookie';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
@Injectable({ providedIn: 'root' })
export class AuthReset {
private httpBackend = inject(HttpBackend);
private http = new HttpClient(this.httpBackend);
private refreshSubject = new BehaviorSubject<string | null>(null);
private refreshInProgress = false;
private injector = inject(Injector);
private readonly REFRESH_URL = '/auth/refresh';
setNewRefreshToken(): Observable<any> {
if (this.refreshInProgress) {
return this.refreshSubject.pipe(take(1));
}
this.refreshInProgress = true;
return this.http.get(this.REFRESH_URL, {
headers: { Refresh: Cookies.get('refId') ?? '' },
}).pipe(
tap((response: any) => {
this.setNewToken(response);
this.refreshInProgress = false;
this.refreshSubject.next(response.accessToken);
}),
map((response: any) => response.accessToken),
catchError((err) => {
this.refreshInProgress = false;
this.logout();
return throwError(() => err);
})
);
}
logout(url?: string) {
localStorage.clear();
const router = this.injector.get(Router);
const translate = this.injector.get(TranslateService);
translate.use('en');
Cookies.remove('sessId');
Cookies.remove('refId');
router.navigate(['/auth'], url ? { queryParams: { returnUrl: url } } : {});
}
private setNewToken(token: any) {
if (!token?.accessToken || !token?.refreshToken) {
this.logout();
} else {
Cookies.set('sessId', token.accessToken);
Cookies.set('refId', token.refreshToken);
}
}
}🚀 Wrapping Up
With Angular 20’s new functional interceptors and DI improvements, you can now build cleaner, lighter, and safer HTTP layers.
Get Faisal Ahmed’s stories in your inbox
Join Medium for free to get updates from this writer.
By separating:
- header injection (
authInterceptor) - error management (
errorInterceptor) - token refresh (
AuthReset)
…you get a fully modular, extensible system that scales across any project.







