Angular Functional Interceptors: Auth Headers, Retry, Logging, Error Normalization [2026]

Interceptors sit between your code and HttpClient — they see every request leaving and every response coming back. Use them for things you want on EVERY request: auth headers, retries, logging, error normalization, request IDs. Since v15, interceptors are functions, not classes. This lesson walks the modern API with concrete real-world interceptors.

This is lesson 6.2, following lesson 6.1 on HttpClient fundamentals. After this lesson you can centralize cross-cutting HTTP concerns instead of repeating them at every call site.

The shape

An interceptor is a function with this signature:

import { HttpInterceptorFn } from '@angular/common/http';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  const cloned = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  });
  return next(cloned);
};

Three mechanics:

  1. req — the outgoing HttpRequest. Immutable; you .clone(...) to modify.
  2. next(req) — pass the request along to the next interceptor (or to the network if you’re last). Returns an Observable<HttpEvent>.
  3. Return value — the Observable that emits response events. You can pipe operators onto it before returning.

Wire interceptors at app config:

import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withFetch(),
      withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor]),
    ),
  ],
};

Interceptors run in array order on the request, REVERSE order on the response (the last interceptor sees the response first, the first sees it last).

Pattern 1: Auth header

The canonical use case — attach a Bearer token:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.token();
  if (!token) return next(req);   // unauthenticated request — pass through unchanged

  const authed = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  });
  return next(authed);
};

Every HTTP call now sends the auth token. No .set('Authorization', ...) at any call site.

Pattern 2: Skip-auth allowlist

For an interceptor that should skip certain URLs (login endpoint, third-party APIs):

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  // Skip external domains and login
  if (!req.url.startsWith('/api/') || req.url.endsWith('/login')) {
    return next(req);
  }
  // ...add auth header
};

The req.url and req.headers are read-only — perfect for filtering decisions.

Pattern 3: Logging

Log every request and its duration:

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const start = Date.now();
  return next(req).pipe(
    tap({
      next: () => console.log(`${req.method} ${req.url} (${Date.now() - start}ms)`),
      error: (err) => console.error(`${req.method} ${req.url} failed: ${err.status}`),
    }),
  );
};

Wraps the response Observable with tap operators. Logs both success and failure. Useful for development; not for production traffic at scale.

Pattern 4: Retry with exponential backoff

import { retry, timer } from 'rxjs';

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    retry({
      count: 2,
      delay: (error, retryCount) => {
        // Only retry on 5xx or network errors
        if (error.status >= 500 || error.status === 0) {
          return timer(retryCount * 500);   // 500ms, 1000ms
        }
        throw error;   // don't retry on 4xx
      },
    }),
  );
};

Three retries on transient errors, increasing delay between attempts. Don’t retry 4xx (client errors don’t fix themselves) or POST/PUT (non-idempotent — could duplicate).

For production retry policies, use a library like @datadog/retry for the strategy — interceptor just consumes it.

Pattern 5: Error normalization

Server error responses come in many shapes; interceptor normalizes them into a consistent client-side error:

import { catchError, throwError } from 'rxjs';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    catchError((err) => {
      const message = err.error?.message ?? err.statusText ?? 'Unknown error';
      const normalized = new HttpError(err.status, message, err.error);
      return throwError(() => normalized);
    }),
  );
};

class HttpError extends Error {
  constructor(public status: number, message: string, public data?: any) {
    super(message);
    this.name = 'HttpError';
  }
}

Now every component sees a HttpError with .status and .message. Components don’t have to know that some servers return { message: ... }, others return { error: ... }, others return plain text.

Pattern 6: Request IDs for distributed tracing

export const requestIdInterceptor: HttpInterceptorFn = (req, next) => {
  const cloned = req.clone({
    setHeaders: { 'X-Request-ID': crypto.randomUUID() },
  });
  return next(cloned);
};

Every request gets a unique ID. The server can include it in error logs; you can correlate client + server logs for the same user action.

Pattern 7: Token refresh on 401

The trickiest interceptor — refresh the auth token and retry the original request when the server returns 401:

import { catchError, switchMap, throwError } from 'rxjs';

export const tokenRefreshInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  return next(req).pipe(
    catchError((err) => {
      if (err.status !== 401) return throwError(() => err);

      return auth.refresh$().pipe(
        switchMap(newToken => {
          const retried = req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } });
          return next(retried);   // retry the original request with the new token
        }),
      );
    }),
  );
};

The next(retried) here doesn’t run OTHER interceptors again — it picks up at the network layer. For most apps this is correct; the auth header is already on the retried request.

Know this pattern’s edge cases — what happens with concurrent 401s, what happens when the refresh itself returns 401. A production-grade version uses a shared refresh Observable (shareReplay(1)) to coalesce concurrent retries.

Ordering matters

Interceptors run in this order on the REQUEST:

withInterceptors([
  authInterceptor,        // 1. adds auth header
  requestIdInterceptor,    // 2. adds request ID
  loggingInterceptor,      // 3. starts timer
])

And in REVERSE order on the RESPONSE:

// response chain: loggingInterceptor sees response first, then requestIdInterceptor, then authInterceptor

In practice the order rarely matters for the response — but it does for the REQUEST. The errorInterceptor should usually be FIRST (so it catches errors from all downstream interceptors). The loggingInterceptor should usually be LAST (so it logs after all modifications are applied).

Context — passing data between interceptors

A request can carry interceptor-specific context via HttpContext:

import { HttpContextToken, HttpContext } from '@angular/common/http';

export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
export const RETRY_COUNT = new HttpContextToken<number>(() => 2);

// At the call site:
this.http.get('/api/foo', {
  context: new HttpContext()
    .set(SKIP_AUTH, true)
    .set(RETRY_COUNT, 5),
});

// In the interceptor:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.context.get(SKIP_AUTH)) return next(req);
  // ...add header
};

The context lets a SPECIFIC call opt out of an interceptor or pass per-call configuration. Useful for fine-grained control without writing different services.

Common gotchas

Symptom Cause Fix
Interceptor doesn’t fire Forgot withInterceptors([...]) in provideHttpClient Add it
Cloned request loses some headers setHeaders ADDS, headers REPLACES Use setHeaders for additive, headers for full replacement
Modified body doesn’t reach server req.body is read-only — must clone req.clone({ body: newBody })
401 retry loops forever The refresh interceptor doesn’t bail when refresh itself fails Handle the second 401 by logging out
inject() in interceptor throws Interceptors run in an injection context — should work Make sure you call inject() at the TOP of the function, not inside a nested callback
Order-sensitive interceptors break Reordered the array Test the order; document why if non-obvious

What’s next

Lesson 6.3 covers httpResource() — the signal-shaped HTTP API with built-in caching and cancellation. Lesson 6.4 walks error handling and retry patterns in depth. Lesson 6.5 covers caching strategies. Lesson 6.6 walks server-sent events and streaming.

Try it yourself

Three interceptors composed:

import { Component, inject } from '@angular/core';
import { HttpClient, HttpInterceptorFn, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { tap, catchError, throwError } from 'rxjs';
import { bootstrapApplication } from '@angular/platform-browser';

const authInterceptor: HttpInterceptorFn = (req, next) =>
  next(req.clone({ setHeaders: { Authorization: 'Bearer demo-token' } }));

const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const start = Date.now();
  return next(req).pipe(tap(() => console.log(`${req.method} ${req.url} - ${Date.now() - start}ms`)));
};

const errorInterceptor: HttpInterceptorFn = (req, next) =>
  next(req).pipe(catchError(err => throwError(() => new Error(`HTTP ${err.status}: ${err.message}`))));

@Component({
  selector: 'app-root',
  template: `<button (click)="go()">Fetch</button>`,
})
class App {
  private http = inject(HttpClient);
  go() {
    this.http.get('https://jsonplaceholder.typicode.com/users/1').subscribe(
      u => console.log('Got:', u),
      e => console.error('Err:', e.message),
    );
  }
}

bootstrapApplication(App, {
  providers: [provideHttpClient(withFetch(), withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor]))],
});

Click the button. Watch the console: the auth header is added, the request is logged with timing, and any error is normalized.

YouI see old code with class-based interceptors (@Injectable + HttpInterceptor interface). Should I migrate?
YouYes — class-based interceptors still work but functional is cleaner: no class, no @Injectable, no provideHttpClient + HTTP_INTERCEPTORS DI dance. The migration is mostly mechanical: extract the intercept() method body into a function, replace this.inject(X) with inject(X) at the top, register via withInterceptors([fn]) instead of multi-provider. Lesson 5.3 has the same migration story for route guards — the framework is moving consistently toward functional APIs.

Lesson 6.3 picks up httpResource() — the signal-shaped HTTP API.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *