Ch. 4 · Angular

Angular HTTP Interceptors and Request Immutability

Functional interceptors in Angular 19 and 20: why headers.set() silently does nothing, how clone() works, chain order, retries, and token leaks.

~6 min readadvancedupdated Oct 7, 2026

An HTTP interceptor sits between HttpClient and the network. Every request passes through it on the way out, every response passes back through it on the way in. That makes interceptors the natural place for cross-cutting concerns: attaching an auth token, logging timings, retrying flaky calls, mapping a 401 to a sign-in prompt.

Interview questions on interceptors cluster around three things: why requests are immutable (and the bug you get if you forget), what order interceptors run in, and what can go wrong with authentication. This note covers all three with a chain of three functional interceptors, run through HttpTestingController with Angular 20.3 under Node. The log lines below are that run’s real output. Angular 19 has the same API.

Functional interceptors and how they’re registered

Since Angular 15, an interceptor can be a plain function of type HttpInterceptorFn. It receives the request and a next function, and returns an observable of events:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(Auth).token();
  if (!token || !req.url.startsWith('/api/')) return next(req);
  return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

// app.config.ts
provideHttpClient(withInterceptors([authInterceptor, timingInterceptor, retryInterceptor]));
TypeScript

Functional interceptors run in an injection context, so inject() works at the top of the function. Class-based interceptors (HTTP_INTERCEPTORS with withInterceptorsFromDi()) still work, but the functional style is what the docs recommend and what you’ll see in new code.

Requests are immutable, and that bug is silent

HttpRequest and HttpHeaders are immutable. Methods like headers.set() and headers.append() don’t change the object; they return a new one. The interceptor in the test checked exactly that:

auth: headers.set returned new object? true; original still has header? false
Text

So this common first attempt does nothing:

export const brokenAuth: HttpInterceptorFn = (req, next) => {
  req.headers.set('Authorization', 'Bearer abc'); // result thrown away
  return next(req);
};
TypeScript

It compiles without a warning, because calling a method and ignoring its return value is legal TypeScript. The request goes out without the header, and the server returns 401. Assigning to a property directly, req.url = '/x', is caught at compile time (TS2540: Cannot assign to 'url' because it is a read-only property), though only by the type system. At runtime, the test’s assignment went through.

The right way is clone() with the change described:

next(req.clone({ setHeaders: { Authorization: 'Bearer abc' } }));
next(req.clone({ url: req.url.replace('/v1/', '/v2/') }));
next(req.clone({ setParams: { locale: 'en-GB' } }));
TypeScript

Why immutable? Because a request can be sent more than once. Retries re-subscribe to the same pipeline, and if an interceptor mutated the shared request, the second attempt would see changes made during the first, like a header appended twice. Immutability makes each interceptor’s input predictable, whatever ran before.

Order: in on the way out, reversed on the way back

The chain was [authInterceptor, timingInterceptor, retryInterceptor]. For one GET to /api/orders, where the first response was a 503 and the retry got a 200:

auth: headers.set returned new object? true; ...
timing: request GET /api/orders
retry: attempt 1
backend saw Authorization=Bearer abc
retry: attempt 2
timing: response 200
component got [{"id":1}]
timing: finalize
Text

Requests flow through the array in order: auth, then timing, then retry, then the backend. Responses flow back in reverse: the backend’s response passes the retry interceptor first, then timing, then auth, then your component. Think of nested function calls: the first interceptor wraps everything after it.

Look at the retry. “attempt 2” appears, but there’s no second “auth” or “timing: request” line. retry() re-subscribes to the observable it was given, which is next(req) from its own position. Only interceptors after it in the chain (here, none) run again. That has real consequences:

  • If the auth interceptor refreshes an expired token, it must come after the retry, or the retry resends the same stale token.
  • If a timing interceptor comes before the retry, it measures both attempts together as one request, which may or may not be what you want.

When an interviewer asks “does interceptor order matter?”, this is the concrete example to give.

Don’t send your token to everyone

An auth interceptor that adds the header to every request leaks the token to any third-party URL the app calls: a maps API, a CDN, an analytics endpoint. The test sent a request to https://maps.example.com/tiles:

auth: skip https://maps.example.com/tiles
backend saw Authorization=null
Text

The interceptor checks the URL and attaches the token only for the app’s own API. Matching a path prefix (/api/) is fine for relative URLs. If your API lives on another origin, compare the parsed origin exactly (new URL(req.url, location.origin).origin === environment.apiOrigin) instead of includes('api'), which https://api.attacker.example would happily pass.

Per-request options with HttpContext

Sometimes one call needs to opt out: a public config endpoint that must not send credentials, or a background poll that shouldn’t trigger the global spinner. Don’t encode that in a custom header the server will see. Use an HttpContextToken, which travels with the request but is never sent:

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

// in the interceptor
if (req.context.get(SKIP_AUTH)) return next(req);

// at the call site
this.http.get('/api/public/config', { context: new HttpContext().set(SKIP_AUTH, true) });
TypeScript

The test confirmed the public config request went out without an Authorization header while still matching /api/.

Handling 401s without loops

The response side is where the trickiest interceptor code lives. A typical 401 handler catches the error, refreshes the token, and retries the original request with the new token:

export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(Auth);
  return next(req).pipe(
    catchError((err: HttpErrorResponse) => {
      if (err.status !== 401 || req.context.get(IS_REFRESH)) return throwError(() => err);
      return auth.refresh().pipe(
        switchMap((token) => next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))),
      );
    }),
  );
};
TypeScript

This is illustrative, but two details matter in any version. The refresh call itself must be marked (here with an IS_REFRESH context token) so a 401 from the refresh endpoint doesn’t trigger another refresh, forever. And when several requests fail at once, auth.refresh() should share one in-flight refresh (for example with shareReplay(1) on a cached observable) rather than firing ten refresh calls. Both come up as follow-up questions.

Testing interceptors

provideHttpClient(withInterceptors([...])) plus provideHttpClientTesting() in TestBed gives you HttpTestingController. You make a request through HttpClient, call expectOne(url) to grab what reached the “backend”, assert on request.headers, and flush() a response. That’s exactly how the output above was produced. Test the interceptor through HttpClient rather than calling the function directly, so the chain order is the real one. The note on HTTP request contract tests goes further.

Interview practice

A team’s auth interceptor is withInterceptors([retryInterceptor, authInterceptor]). authInterceptor reads the current token from a signal and attaches it; another part of the app refreshes the token when it’s close to expiry. Users occasionally see a 401 even though the retry interceptor retries 401s once. Why doesn’t the retry help, and what would you change?

Answer

The retry interceptor is first, so it wraps next(req) where next leads to the auth interceptor. Its retry() re-subscribes from its own position, so the auth interceptor does run again on the second attempt and reads the token signal again. If the retry still fails with 401, it’s because the token hadn’t been refreshed yet: the retry fires immediately, before the separate refresh process has finished. The second request carries the same expired token.

I’d stop relying on timing. Handle 401s in a dedicated interceptor that explicitly refreshes and then retries the request with the new token, with the refresh shared between concurrent requests and excluded from its own handling via an HttpContextToken. I’d also make sure the generic retry interceptor doesn’t retry 401 or 403 at all (retrying an authorization failure with the same credentials can’t succeed) and only retries idempotent methods on network errors and 5xx responses, with a short backoff. Finally I’d verify the auth interceptor uses req.clone({ setHeaders }), since a bare req.headers.set() would produce the same symptom on every request, not just occasionally.

Further reading

More in Angular

read ✓Angular · mid

Angular HTTP Request Contract Tests

Test an Angular service's HTTP contract with HttpTestingController: exact URLs and params, DTO mapping, 404 and network failures, and verify().

~6 min readread →
read ✓Angular · easy

Angular OnPush and Input Reference Changes

Why an OnPush child ignores user.role = 'admin' but updates for a new object, what else marks it dirty, and how signal inputs change the picture.

~6 min readread →
esc