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.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().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.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.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:
is immutable. Nothing in it can be swapped in place. The assignment HttpRequest
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.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.
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.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.
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.