We use cookies to enhance your experience on the site
CodeWorlds

The barrier on the road - HTTP interceptors

You already know how to put the clan seal on a letter: you build an

HttpHeaders
object with
set()
and hand the finished set over in the request options. With one method that is two lines of work. The trouble starts the day the castle brings in a new rule.

And the rule reads like this: every courier who leaves the walls carries a watch token with him - a token, in our language. Not only the one sent for the roll of samurai, but also the one with the recruit, the one with the promotion order, the one asking for a name to be struck off the register. Your clan registry has five methods, so five times you write out the same line with the

Authorization
header. The armoury service has four, so now there are nine copies. Tomorrow a second header is required and you correct nine places at once. The day after, somebody adds a tenth method and forgets the seal, the server answers 401, and the evening goes on hunting for one missing line.

With the shields from the lesson on failures it is exactly the same. An expired token looks identical in every method: status 401, and the user has to be sent back to the login screen. Copied into nine methods, the same error handling is the same work done nine times - and nine places where it can be broken.

On the Tokaido road nobody solved this by posting a scribe in every house. They built a barrier station: a post set across the road, which everyone travelling that way has to pass. One place and one custom - stamp the seal, write the crossing into the book, let the traveller through. In Angular that barrier is called an interceptor, and this lesson is about it, @name. It is what the lesson on request options was pointing at when it deliberately left the

Authorization
header out of the options: one place through which every request in the application passes.

What the barrier is, and what it is not

A barrier is a function you never call yourself. Angular calls it, and at a very precise moment: the request has already been built by

HttpClient
with everything you gave it, but it has not yet set off across the network. Your function is handed that finished request and may swap it for another one before it goes. It is also handed a way to pass the request onwards, and gets back, in exchange, the stream of events returning from the other side, which it is free to look into as well. One registration covers every request in the application, without adding anything to your services.

Remember it in a single sentence, because it is the heart of the whole lesson: an interceptor exists to modify requests and responses globally, and the most common reason for putting one up is to add a token to every request.

It is worth fencing that off straight away from three things a barrier gets confused with. First, it is not a wall for blocking unwanted requests. The requests in your application are sent by your own code, so there is nothing to sift out, and an interceptor practically always ends by passing the request on. Refusing entry is the job of the route guards you posted at the gates in the module on routing, and of the server on the other side. Second, a barrier is not a mechanism for caching responses: the memory for data already fetched you built in the lesson on shields, inside the service, with a map and the

shareReplay
operator, and not one interceptor took part in it. The barrier itself remembers nothing. Third, it does not deal with compressing data - that is settled between the browser and the server through the
Content-Encoding
header, and your code never even sees the bytes it would have to work on.

Do not mix the barrier up with a gate guard either. A route guard answered once per navigation and said "you may pass" or "turn back". The barrier stands on the road and travels with every single request, and its answer is not a verdict but traffic let through. What they have in common is that both are ordinary functions and both can reach for a service through

inject()
.

The shape of a barrier: HttpInterceptorFn

So that TypeScript knows what kind of function to expect here, the

@angular/common/http
package exports a ready-made type alias called
HttpInterceptorFn
. You sign your constant with it after a colon, and then you do not have to write a single annotation inside - TypeScript works out the types of both parameters by itself.

There are two parameters and you will use both in every barrier you ever write. The first, traditionally named

req
, is the finished request: an
HttpRequest
object with the method, the address, the headers and the payload. The second,
next
, is the pass-it-on function - called with a request, it sends that request down the next stretch of road and returns the stream of events that come back from it. That stream is at the same time what your barrier is expected to hand outwards.

Let us start with a barrier that stops nobody, so that you see the construction itself first, with no logic inside.

1// interceptors/audit.interceptor.ts
2import { HttpInterceptorFn } from '@angular/common/http';
3
4export const auditInterceptor: HttpInterceptorFn = (req, next) => {
5  return next(req);
6};

This barrier takes the request and gives it back untouched, and its resulting stream is exactly what

next
returned. Notice what is not here: no class, no decorator, no
new
keyword. It is an ordinary constant holding an arrow function, precisely as with route guards. More importantly, this function will not run even once for now - the file sits in the project and Angular knows nothing about it, because nobody has registered it anywhere yet.

Learn the name of the type letter by letter, because three others sound just as sensible and each of them fails for a different reason. I checked all three with the TypeScript compiler on Angular 19. The name

InterceptorFunction
simply does not exist - the import ends with error TS2305 saying that
@angular/common/http
has no such export. The name
HttpHandler
does exist, but it describes something else: it is the type of the object that passes requests on in the old class-based variant, an object with a
handle()
method, and not the type of your function; signing an arrow function with it ends in error TS2322. And finally there is
HttpInterceptor
without the ending, which is the real trap, because it belongs to the earlier variant: an interface implemented by a class with an
intercept()
method, registered afterwards through
withInterceptorsFromDi()
. An arrow function does not fit under that interface either - TS2322 again. The functional variant arrived in Angular 15 and it is the one I recommend, @name: it is shorter, it needs neither a class nor an entry in the providers, and
inject()
gives you inside it everything you once had to reach for through a constructor.

Standing the barrier on the road

The function alone will intercept nothing until you point out its place on the road. There is exactly one such place and you know it from the first waypoint in this module: the

provideHttpClient()
call in the application configuration file. Barriers are registered inside it with the
withInterceptors()
function, which takes an array of functions.

1// app.config.ts
2import { ApplicationConfig } from '@angular/core';
3import { provideHttpClient, withInterceptors } from '@angular/common/http';
4import { auditInterceptor } from './interceptors/audit.interceptor';
5
6export const appConfig: ApplicationConfig = {
7  providers: [
8    provideHttpClient(withInterceptors([auditInterceptor]))
9  ]
10};

Look at that one line token by token, because you will be reproducing it: first the name

provideHttpClient
, then the opening parenthesis, then the name
withInterceptors
, then the array with the barriers, and at the end the parenthesis that closes the whole call. From this moment on, every request sent through
HttpClient
passes through
auditInterceptor
- which still lets everyone by unchanged, but is already taking part in the journey.

The most important part is what has not changed. The clan registry, the armoury and every other service look exactly as they did yesterday: not one new line, not one new import. The service does not know that a post has been set up on the road, and that is the whole strength of this solution. The components have not changed either, because they talk to the service and not to the network.

Two details in that array can hurt. There are no parentheses after the name

auditInterceptor
- you pass the function itself and not its result, just as with route guards. The form
[auditInterceptor()]
would try to call the barrier immediately, at the moment the configuration file is loaded, and this particular slip is one TypeScript catches for you: I checked it with the compiler, and it answers with error TS2554 saying that 2 arguments were expected and 0 were given. Nor can you put an old-style class into that array -
withInterceptors
accepts functions only, and a class ends with error TS2322 saying that the type of the class is not assignable to
HttpInterceptorFn
.

A seal on every scroll

Time for the barrier to do some real work. Let us agree that the project contains an

AuthService
with a
getToken()
method, which returns the current token or
null
when nobody is logged in - the same service you asked for the seal at the gate in the module on routing. You reach for the service with the
inject()
function from
@angular/core
, because the body of an interceptor is an injection context: it is Angular that calls this function, and Angular that prepares that context.

Before you add the header, though, you have to learn the rule that governs everything here:

HttpRequest
is immutable. Nothing in it can be swapped in place. The assignment
req.headers = ...
is not a matter of good style but of compilation error TS2540 about assigning to a read-only property; I checked the same for
req.url
and the result is identical. The reason is practical: one and the same request travels through a whole row of barriers, and when it is retried it may take to the road a second time. If every post could scrape at it in its own way, nobody would know which version of the order really left the walls.

Instead of scraping you therefore make a copy. The

clone()
method called on the request is what serves that purpose: it returns a new
HttpRequest
object, carries over into it everything you do not overwrite - the method, the address, the payload - and lays on top the fields you give it in an object.

1// interceptors/auth.interceptor.ts
2import { HttpInterceptorFn } from '@angular/common/http';
3import { inject } from '@angular/core';
4import { AuthService } from '../services/auth.service';
5
6export const authInterceptor: HttpInterceptorFn = (req, next) => {
7  const authService = inject(AuthService);
8  const token = authService.getToken();
9
10  if (token) {
11    const authReq = req.clone({
12      headers: req.headers.set('Authorization', `Bearer ${token}`)
13    });
14    return next(authReq);
15  }
16
17  return next(req);
18};

I ran this barrier on a real Angular 19 HTTP chain against a stub server. What reached the server was a request with an

Authorization
header holding
Bearer
and the token glued after it, so the seal arrived where it was meant to. While I was there I checked the most important thing of all: after the whole operation the original
req
object still has no
Authorization
header
. The copy came into being alongside it, the copy got the header, and it is the copy we handed to
next
. The address and the method went into the copy unchanged -
clone()
does not make you write them out again.

Inside that copy a second immutability is at work, the one from the lesson on request options:

req.headers.set(...)
adds nothing in place either, but returns a new set of headers. That is why the result of the call goes straight into
clone()
. If on a separate line you wrote merely
req.headers.set('Authorization', ...)
and did nothing with the result, the request would ride out with no seal at all, and the compiler would not say a word.

The barrier itself still stands nowhere until you register it - and the registration looks exactly as it did a moment ago, only with a different name in the array:

provideHttpClient(withInterceptors([authInterceptor]))
.

Let me disarm three misunderstandings about

clone()
while we are here, because they circulate around the dojo stubbornly. The first: that a copy sends the request faster - no, copying a few fields speeds nothing up, and if anything it costs a little work;
clone()
exists so that anything can be changed at all, not so that it happens sooner. The second: that without
clone()
the interceptor will not register
- no, registration is purely the business of
withInterceptors
and knows nothing about cloning; the
auditInterceptor
from the start of this lesson had not a single
clone()
in it and registered without a murmur. The third: that it is merely a convention and you may modify the request directly - you may not, and it is not an agreement between programmers but a matter of types: the compiler rejects an assignment to
headers
with error TS2540.

When a service is needed for nothing but a single question, those first two lines may be folded into one. The result of

inject()
can be questioned with a dot straight away.

1const token = inject(AuthService).getToken();

This form means exactly the same as the previous two lines, and in practice it is the most common one - the

authService
constant was of no further use anyway. Do notice where this line stands, though: among the first lines of the barrier body.
inject()
may only be called synchronously, in an injection context, so do not move it into a function that will run later - in a moment you will see what happens when somebody tries. The rest of the barrier interior is always in the same order: first get the token, then make a copy of the request with the header on it, then hand that copy to
next
.

What you can see on the way back

So far we have only been looking one way. But

next(req)
is not a dead end - it returns a stream, and a stream does not have to be handed on untouched. You are allowed to hook it into
pipe()
and add operators, exactly as you did in the module on RxJS, among the ninja scouts.

So let us build a book of crossings: a barrier that measures how long the courier was on the road. For plain watching there is the

tap
operator, which passes values through unchanged while letting you do something with them along the way. Events are recognised by their
type
field, and the names of their kinds come from the
HttpEventType
enumeration you know from the lesson on request options - the one we care about is
HttpEventType.Response
, the end of the journey.

1// interceptors/logging.interceptor.ts
2import { HttpEventType, HttpInterceptorFn } from '@angular/common/http';
3import { tap } from 'rxjs';
4
5export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
6  const started = Date.now();
7
8  return next(req).pipe(
9    tap(event => {
10      if (event.type === HttpEventType.Response) {
11        console.log(req.method, req.url, Date.now() - started, 'ms');
12      }
13    })
14  );
15};

Once this is running, one row appears in the console for every request: the method, the address and a number of milliseconds. I will not promise you any particular number, because it depends on the network and on the server - against a stub server on the same machine mine kept coming out as a single millisecond, and against a real castle across the mountains it will be hundreds. What matters more is what has not changed: the request went out exactly as it would without the barrier, the response came back to the component untouched, and

tap
swapped nothing in the stream - it only watches.

One detail is surprising at the first meeting. I checked which events pass through the barrier during an ordinary

get()
, and there were two: first
Sent
, then
Response
. The barrier sees the full traffic on the road, even though the component asked only for the body of the response - the sifting of events happens higher up, after the interceptors. That is why the condition on
HttpEventType.Response
is not decoration: without it the entry in the book would also be written at the moment of sending, that is, before there was anything to measure.

While we are at it, remember when a barrier speaks at all. I checked it with a counter: after the stream was built but before any subscription, the function had not run once; after the first subscription, once; after the second, twice. HTTP streams are lazy, so a barrier is crossed once per actual journey of a courier, and not once per application.

An expired token, or one error handled in one place

Since operators may be added on the way back, you can also lay error handling there - and there is one error that looks the same in every request in the whole application. Status 401 means "your token is not valid". Whatever you were doing, the ending is the same: clear the token and send the user back to the login screen.

You already know all the building blocks.

catchError
catches an error out of the stream,
throwError
lets it out further,
HttpErrorResponse
describes a response carrying an error - that is the lesson on shields.
Router
and its
navigate()
come from the module on the Tokaido trails. The only new thing here is one rule, the one about
inject()
, and you are about to see why it matters so much.

1// interceptors/error.interceptor.ts
2import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
3import { inject } from '@angular/core';
4import { Router } from '@angular/router';
5import { catchError, throwError } from 'rxjs';
6import { AuthService } from '../services/auth.service';
7
8export const errorInterceptor: HttpInterceptorFn = (req, next) => {
9  const authService = inject(AuthService);
10  const router = inject(Router);
11
12  return next(req).pipe(
13    catchError((error: HttpErrorResponse) => {
14      if (error.status === 401) {
15        authService.logout();
16        router.navigate(['/login']);
17      }
18      return throwError(() => error);
19    })
20  );
21};

I ran this against a server answering with status 401. The result: the token was cleared, the router was ordered to go to

/login
, and the component received the error all the same, with status 401. That last part is the crucial one here and it follows from the
throwError
at the end: the barrier does not swallow the error, it lets it out further, so the handling you wrote earlier in the service does not stop working for a moment. If instead of that you returned your own, made-up response, the component would never learn that anything had gone wrong.

Notice where both

inject()
calls stand: at the very top of the barrier body, before
pipe()
. It is tempting to write them only inside
catchError
, where they are actually needed. I checked what happens then: the code compiles without a single complaint, but at the first 401 the application blows up with message NG0203, saying that
inject()
must be called from an injection context. The reason is simple: the context exists only at the moment Angular calls your barrier, while the
catchError
callback fires much later, when the error comes back from the network - the context is long gone by then. Constants obtained at the top survive until that moment without trouble, and that is exactly the point.

The retry from the lesson on shields has a place here just as good as

catchError
, because it works on the same stream from
next(req)
. We will not repeat it - the way of doing it is the same, the only difference being that written once it now covers every request.

The order of barriers on the road

Barriers on one road stand one after another, and the order of entries in the array is the order in which they are passed. Let us register all three at once.

1// app.config.ts
2import { ApplicationConfig } from '@angular/core';
3import { provideHttpClient, withInterceptors } from '@angular/common/http';
4import { authInterceptor } from './interceptors/auth.interceptor';
5import { loggingInterceptor } from './interceptors/logging.interceptor';
6import { errorInterceptor } from './interceptors/error.interceptor';
7
8export const appConfig: ApplicationConfig = {
9  providers: [
10    provideHttpClient(
11      withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor])
12    )
13  ]
14};

I traced a passage through three such posts and the result is worth remembering: on the way out the courier passes them in the order they are written, that is auth, logging, error, and only then goes out into the network. On the way back he returns in the reverse order: error, logging, auth. That is natural, because each barrier wraps the stream of the previous one - the further into the array, the closer to the network.

The practical conclusion is that the barrier which adds the seal should stand earlier than the one that looks at the outcome. Then the timer measures the passage of an already sealed request, and the error handling sees the response to the very request that really left the walls. The reversed order will compile too and will even work, but you will start seeing in your book requests without the header you did after all glue on.

A barrier you are allowed to skip

There is one road, however, on which the seal is in the way. The motto carved over the gate may be read by anyone without a token, and sending the token where it is not needed is at best pointless chatter, at worst showing a clan secret to a stranger.

It is tempting to test the address inside the barrier, something along the lines of "if the address ends with motto, let it through without a seal". I advise against that, @name: a condition built on the text of an address breaks after every change of path, and nobody remembers it is there. The better way is for the caller to attach a note to the request, meant for the barrier.

HttpContext
is what serves that purpose: a small bundle tied to the request which - and this is the important part - never travels over the network. It exists only in the browser and is there so that your code can talk to your interceptors. Keys to that bundle are created with the
HttpContextToken
class, given a function that returns the default value.

1// interceptors/auth.context.ts
2import { HttpContextToken } from '@angular/common/http';
3
4export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);

The angle brackets say that a boolean lies under this key, and the function

() => false
answers the question "and what if nobody wrote anything in". I checked both paths on a live object: a freshly created context returns
false
for this key, and a request whose context was never touched at all returns
false
as well. By default, then, nobody skips the barrier, and that is exactly how it should be - skipping has to be asked for out loud.

Now the barrier has to read that note. Every request has a

context
field, and out of it you take the value with the
get()
method, giving it the key.

1// interceptors/auth.interceptor.ts
2import { HttpInterceptorFn } from '@angular/common/http';
3import { inject } from '@angular/core';
4import { AuthService } from '../services/auth.service';
5import { SKIP_AUTH } from './auth.context';
6
7export const authInterceptor: HttpInterceptorFn = (req, next) => {
8  if (req.context.get(SKIP_AUTH)) {
9    return next(req);
10  }
11
12  const token = inject(AuthService).getToken();
13  if (!token) {
14    return next(req);
15  }
16
17  return next(req.clone({
18    headers: req.headers.set('Authorization', `Bearer ${token}`)
19  }));
20};

The only new thing here is the first pair of lines - the rest is the same seal as before, only written so that every case ends with a

return
of its own instead of hiding inside a nested
if
. The context check stands before
inject()
, and that is deliberate: if the request is to go without a seal, there is no reason to ask the service for a token.

That leaves the calling side, that is the one courier who is to walk past the barrier. You attach the note in the same options object in which you gave headers and

responseType
- it is simply another field of it.

1// services/motto.service.ts
2import { HttpClient, HttpContext } from '@angular/common/http';
3import { Injectable, inject } from '@angular/core';
4import { SKIP_AUTH } from '../interceptors/auth.context';
5
6@Injectable({ providedIn: 'root' })
7export class MottoService {
8  private http = inject(HttpClient);
9
10  readMotto() {
11    return this.http.get('/api/motto', {
12      responseType: 'text',
13      context: new HttpContext().set(SKIP_AUTH, true)
14    });
15  }
16}

I sent two requests through this configuration at once. The ordinary one, for the roll of samurai, reached the server with an

Authorization
header. The one for the motto reached it without one - the barrier read the note and let the courier by unsealed. What did not change along the way: to the server both requests look perfectly ordinary, because the bundle with the note went nowhere, and the copy of the request made inside the barrier carries the context onwards together with the rest of the fields.

There is one difference from headers here that is easy to trip over.

HttpHeaders
was immutable and its
set()
returned a new object.
HttpContext
behaves the other way round: I checked, and its
set()
changes the object in place and returns that same object. The chain
new HttpContext().set(...)
therefore works in both cases, but for two different reasons, and only with headers does losing the result cost you the change.

Remember, @name: a barrier is one place on the road that every courier passes through - write the seal in there, and you will stop writing it everywhere else.

Go to CodeWorlds