Fix: Angular HTTP Interceptor Not Working — Requests Not Intercepted
Part of: React & Frontend Errors
Quick Answer
How to fix Angular HTTP interceptors not triggering — provideHttpClient setup, functional interceptors, order of interceptors, excluding specific URLs, and error handling.
The Problem
An Angular HTTP interceptor doesn’t run for outgoing requests:
// Auth interceptor defined — but tokens never attached
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
console.log('Interceptor running'); // Never logged
const token = localStorage.getItem('token');
const authReq = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
return next.handle(authReq);
}
}Or the interceptor runs but error handling doesn’t catch HTTP errors:
intercept(req: HttpRequest<any>, next: HttpHandler) {
return next.handle(req).pipe(
catchError(err => {
if (err.status === 401) this.authService.logout();
return throwError(() => err);
})
);
}
// 401 errors reach the component — interceptor catchError not triggeredOr with Angular 17+ provideHttpClient, interceptors registered the old way don’t work:
// Registered with HTTP_INTERCEPTORS token — but provideHttpClient is in use
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
// Has no effect when using provideHttpClient()Why This Happens
Angular’s HTTP interceptor system changed significantly in Angular 15+, and the migration path introduced several compatibility traps.
The core issue is that Angular now has two distinct interceptor APIs. The legacy approach uses HTTP_INTERCEPTORS injection token with class-based interceptors registered in providers. The modern approach uses provideHttpClient() with either withInterceptors() (functional) or withInterceptorsFromDi() (class-based opt-in). These two systems do not automatically interoperate. If you call provideHttpClient() in app.config.ts but register interceptors with the old HTTP_INTERCEPTORS token, those interceptors are silently ignored unless you also pass withInterceptorsFromDi().
Beyond the API mismatch, interceptor scope matters. An interceptor provided in a lazy-loaded module only applies to HttpClient instances injected within that module’s injector scope. And injecting HttpBackend directly — which some libraries do internally — bypasses the entire interceptor chain. Interceptor ordering also trips developers up: interceptors run in registration order for requests but reverse order for responses, so placing an error handler after a logging interceptor means error handling runs before logging on the response path.
Fix 1: Register Interceptors Correctly for Your Angular Version
Angular 14 and earlier — HttpClientModule approach:
// app.module.ts
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptor } from './auth.interceptor';
@NgModule({
imports: [HttpClientModule],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true, // Required — allows multiple interceptors
},
{
provide: HTTP_INTERCEPTORS,
useClass: LoggingInterceptor,
multi: true,
},
],
})
export class AppModule {}Angular 15+ — provideHttpClient() with class-based interceptors:
// app.config.ts
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptor } from './auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptorsFromDi(), // Enable class-based interceptors
),
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true,
},
],
};Angular 15+ — functional interceptors (preferred):
// auth.interceptor.ts — functional style
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (!token) return next(req);
const authReq = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(authReq);
};
// app.config.ts — register functional interceptors
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor]),
),
],
};Mixing functional and class-based interceptors (Angular 15+):
You can use both withInterceptors() and withInterceptorsFromDi() together. Functional interceptors run first:
provideHttpClient(
withInterceptors([loggingInterceptor]), // Runs first
withInterceptorsFromDi(), // Class-based run after
)Fix 2: Write a Complete Auth Interceptor
A production-ready auth interceptor handles token attachment, refresh, and 401 retry:
// auth.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
// Skip auth header for public endpoints
if (req.url.includes('/auth/login') || req.url.includes('/auth/refresh')) {
return next(req);
}
const token = authService.getAccessToken();
// Attach token if available
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401 && !req.url.includes('/auth/refresh')) {
// Token expired — try to refresh
return authService.refreshToken().pipe(
switchMap(newToken => {
const retryReq = req.clone({
setHeaders: { Authorization: `Bearer ${newToken}` },
});
return next(retryReq);
}),
catchError(refreshError => {
// Refresh also failed — log out
authService.logout();
return throwError(() => refreshError);
}),
);
}
return throwError(() => error);
}),
);
};Class-based equivalent:
import { Injectable } from '@angular/core';
import {
HttpInterceptor, HttpRequest, HttpHandler,
HttpEvent, HttpErrorResponse,
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.authService.getAccessToken();
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next.handle(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
return this.authService.refreshToken().pipe(
switchMap(newToken => {
const retried = req.clone({
setHeaders: { Authorization: `Bearer ${newToken}` },
});
return next.handle(retried);
}),
catchError(() => {
this.authService.logout();
return throwError(() => error);
}),
);
}
return throwError(() => error);
}),
);
}
}Fix 3: Set Correct Interceptor Order
Interceptors form a chain. Request modifications apply in registration order (first to last); response modifications apply in reverse (last to first):
// app.config.ts — order matters
provideHttpClient(
withInterceptors([
loggingInterceptor, // 1st — logs request, then logs response last
authInterceptor, // 2nd — adds auth header, handles 401
cacheInterceptor, // 3rd — checks/stores cache, closest to the actual request
]),
)
// Request flow: logging → auth → cache → HTTP request
// Response flow: HTTP response → cache → auth → loggingCommon ordering rules:
- Logging — outermost (first), so it sees the final modified request and the final response
- Auth — before cache, so cached responses don’t bypass auth checks
- Error handling — can be anywhere depending on whether you want to catch errors before or after auth retry
- Retry — after auth, so it retries with the refreshed token
Fix 4: Exclude Specific URLs from Interceptors
Some requests (analytics, third-party APIs, login) shouldn’t have auth headers:
// Functional interceptor — exclude by URL pattern
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const EXCLUDED_URLS = [
'/auth/login',
'/auth/register',
'/public/',
'https://analytics.external.com',
];
const isExcluded = EXCLUDED_URLS.some(url => req.url.includes(url));
if (isExcluded) return next(req);
const token = inject(AuthService).getAccessToken();
if (!token) return next(req);
return next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}));
};Use a custom context token to skip interceptors:
import { HttpContext, HttpContextToken } from '@angular/common/http';
// Define a context token
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
// Interceptor — check the token
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_AUTH)) {
return next(req); // Skip auth for this request
}
// ... attach token
};
// Usage — skip auth for a specific request
this.http.get('/public/health', {
context: new HttpContext().set(SKIP_AUTH, true),
}).subscribe();Fix 5: Handle SSR and Server-Side HttpClient
Angular SSR (Universal) uses a server-side HttpClient that behaves differently from the browser version. Interceptors that rely on browser APIs fail silently on the server.
// Interceptor that works in both browser and SSR
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const platformId = inject(PLATFORM_ID);
if (!isPlatformBrowser(platformId)) {
// SSR — skip browser-only logic
// localStorage doesn't exist on the server
return next(req);
}
const token = localStorage.getItem('token');
if (!token) return next(req);
return next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}));
};Common SSR interceptor mistakes:
- Accessing
localStorageorsessionStorageon the server throwsReferenceError document.cookiedoesn’t exist in Node.js — use theREQUESTinjection token from@angular/ssrto read cookies from the incoming HTTP request- Relative URLs (
/api/data) resolve differently on the server. Use absolute URLs or configure a base URL via an interceptor:
export const baseUrlInterceptor: HttpInterceptorFn = (req, next) => {
const platformId = inject(PLATFORM_ID);
if (!isPlatformBrowser(platformId) && req.url.startsWith('/')) {
// On the server, relative URLs need a base
const serverReq = req.clone({
url: `http://localhost:4000${req.url}`,
});
return next(serverReq);
}
return next(req);
};Fix 6: Write a Logging Interceptor
A logging interceptor that tracks request timing:
// logging.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { tap, finalize } from 'rxjs/operators';
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const started = Date.now();
const { method, url } = req;
console.log(`-> ${method} ${url}`);
return next(req).pipe(
tap({
next: (event) => {
// HttpResponse is an HttpEvent — check type
if (event.type === 4) { // HttpEventType.Response = 4
const elapsed = Date.now() - started;
console.log(`<- ${method} ${url} ${(event as any).status} (${elapsed}ms)`);
}
},
error: (error) => {
const elapsed = Date.now() - started;
console.error(`x ${method} ${url} ${error.status} (${elapsed}ms)`, error.message);
},
}),
finalize(() => {
// Always runs — request completed (success or error)
}),
);
};Fix 7: Handle Errors in Interceptors
A global error handler interceptor:
// error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const notificationService = inject(NotificationService);
const router = inject(Router);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
// Don't show UI errors for background requests
const isSilent = req.context.get(SILENT_ERROR);
if (!isSilent) {
switch (error.status) {
case 0:
notificationService.error('Network error — check your connection');
break;
case 400:
notificationService.error(error.error?.message ?? 'Bad request');
break;
case 403:
notificationService.error('Access denied');
router.navigate(['/forbidden']);
break;
case 404:
// Don't show notification for 404 — let component handle it
break;
case 500:
notificationService.error('Server error — please try again');
break;
}
}
// Re-throw so components can also handle the error
return throwError(() => error);
}),
);
};Fix 8: Test HTTP Interceptors
Testing interceptors with HttpClientTestingModule (module-based) and provideHttpClientTesting (standalone):
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
describe('AuthInterceptor (class-based)', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{ provide: AuthService, useValue: { getAccessToken: () => 'test-token' } },
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify()); // Ensure no unexpected requests
it('attaches Authorization header', () => {
http.get('/api/data').subscribe();
const req = httpMock.expectOne('/api/data');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
req.flush({ data: 'test' });
});
it('redirects to login on 401', () => {
const router = TestBed.inject(Router);
jest.spyOn(router, 'navigate');
http.get('/api/protected').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/protected');
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
expect(router.navigate).toHaveBeenCalledWith(['/login']);
});
});Testing functional interceptors with provideHttpClientTesting (Angular 16+):
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
describe('authInterceptor (functional)', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: { getAccessToken: () => 'token' } },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('attaches token to requests', () => {
http.get('/api/data').subscribe();
const req = httpMock.expectOne('/api/data');
expect(req.request.headers.get('Authorization')).toBe('Bearer token');
req.flush({});
});
});Note: HttpClientTestingModule is the legacy approach. For new standalone Angular apps, use provideHttpClientTesting() alongside provideHttpClient(). Do not mix HttpClientTestingModule with provideHttpClient() — they create separate HttpClient instances and the interceptors from one won’t apply to the other.
Still Not Working?
Lazy-loaded modules and interceptors — interceptors provided in a lazy-loaded module only apply to HttpClient instances injected within that module. For global interceptors, always register in the root AppModule or app.config.ts.
HttpBackend bypasses interceptors — injecting HttpBackend directly (instead of HttpClient) skips all interceptors. Some libraries do this internally. You can’t intercept those requests.
Multiple provideHttpClient() calls — if provideHttpClient() appears in both app.config.ts and a component’s providers, the component gets a separate HttpClient instance without the root-level interceptors.
Interceptor injected in wrong scope — class-based interceptors are instantiated once per injector. If AuthService uses providedIn: 'root' but the interceptor is provided in a feature module, the interceptor gets the root injector’s AuthService — which may have different state than a module-level service.
withFetch() changes transport behavior — Angular 17+ provideHttpClient(withFetch()) uses the Fetch API instead of XMLHttpRequest. Interceptors still run, but upload progress events are not available with withFetch(). If your interceptor relies on HttpEventType.UploadProgress, switch back to the default XHR transport.
JSONP requests bypass interceptors — HttpClient.jsonp() does not pass through the interceptor chain. This is by design because JSONP uses a <script> tag, not an HTTP request. If you need to add auth to a JSONP-style endpoint, switch to a standard CORS request.
Race condition in token refresh — if multiple requests fail with 401 simultaneously, each triggers a token refresh call. Queue concurrent refresh attempts using a shared BehaviorSubject or shareReplay(1) so only one refresh request is made:
private isRefreshing = false;
private refreshSubject = new BehaviorSubject<string | null>(null);
// In the interceptor — queue parallel 401s behind one refresh
if (this.isRefreshing) {
return this.refreshSubject.pipe(
filter(token => token !== null),
take(1),
switchMap(token => next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}))),
);
}For related Angular issues, see Fix: Angular RxJS Memory Leak, Fix: Angular Signals Not Updating, Fix: Angular SSR Not Working, and Fix: Angular Standalone Component Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: RxJS Not Working — Observable Not Emitting, Memory Leak from Unsubscribed Stream, or Operator Behaving Unexpectedly
How to fix RxJS issues — subscription management, switchMap vs mergeMap vs concatMap, error handling with catchError, Subject types, cold vs hot observables, and Angular async pipe.
Fix: Angular Pipe Not Working — Custom Pipe Not Transforming or async Pipe Not Rendering
How to fix Angular pipe issues — declaring pipes in modules, standalone pipe imports, pure vs impure pipes, async pipe with observables, pipe chaining, and custom pipe debugging.
Fix: Angular Signals Not Updating — computed() and effect() Not Triggering
How to fix Angular Signals not updating — signal mutations, computed dependency tracking, effect() cleanup, toSignal() with Observables, and migrating from zone-based change detection.
Fix: Angular Standalone Component Error — Component is Not a Known Element
How to fix Angular standalone component errors — imports array, NgModule migration, RouterModule vs RouterLink, CommonModule replacement, and mixing standalone with module-based components.