Skip to content

Fix: Angular HTTP Interceptor Not Working — Requests Not Intercepted

FixDevs · (Updated: )

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 triggered

Or 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 → logging

Common 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 localStorage or sessionStorage on the server throws ReferenceError
  • document.cookie doesn’t exist in Node.js — use the REQUEST injection token from @angular/ssr to 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 interceptorsHttpClient.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles