After an Angular app's services switch to calling https://api.example.com directly, every POST fails the backend's CSRF check with a 403; why did HttpClient stop sending X-XSRF-TOKEN, and how do you fix it?
answer
- compare the two origins
- the page is app.example.com
- skipped by design, not a bug
- make the API same-origin
- or an allow-listed interceptor
basics
~20 sHttpClient adds the XSRF header only for URLs on the page's own origin; api.example.com is cross-origin, so it is skipped to avoid leaking the token. Serve the API from the app's origin, or add the header for that one trusted origin.
solid answer
~40 sThe built-in interceptor resolves each request URL against the page's location and compares origins. Scheme, host and port must all match, so `https://api.example.com` seen from `https://app.example.com` is cross-origin and the request leaves without `X-XSRF-TOKEN`. That is deliberate: sending the token to other origins would leak it. The cleanest fix is to make the API same-origin again, for example by routing `/api` on the app's host to the backend through a reverse proxy, and a dev-server proxy locally, then call relative URLs. If the API must stay on its own origin, write a small interceptor that adds the header only for that exact origin, reading the value through the injectable `HttpXsrfTokenExtractor`. That also requires the token cookie to be readable on the app's host and the API's CORS configuration to allow the header and credentials.
code
ts · 24 linesimport {PlatformLocation} from '@angular/common';
import {HttpInterceptorFn, HttpXsrfTokenExtractor} from '@angular/common/http';
import {inject} from '@angular/core';
const API_ORIGIN = 'https://api.example.com';
function originOf(url: string, base: string): string | null {
try {
return new URL(url, base).origin;
} catch {
return null;
}
}
export const apiXsrfInterceptor: HttpInterceptorFn = (req, next) => {
const isSafe = req.method === 'GET' || req.method === 'HEAD';
const isApi = originOf(req.url, inject(PlatformLocation).href) === API_ORIGIN;
const token = inject(HttpXsrfTokenExtractor).getToken();
if (isSafe || !isApi || token === null || req.headers.has('X-XSRF-TOKEN')) {
return next(req);
}
return next(req.clone({setHeaders: {'X-XSRF-TOKEN': token}}));
};go deeper
Recall that HttpClient adds the XSRF header only to requests for the app's own origin.
Explain the origin comparison, scheme, host and port, and why a separate API host or port is skipped.
Fix it properly: route the API through the app's origin, or add the header for one exact trusted origin while also handling cookie scope and CORS credentials.
Choose the topology: a same-origin gateway versus a separate API origin, weighing CSRF handling, CORS complexity and cookie scope across teams.
## The symptom The app used to call relative URLs such as `/api/transfers`. A refactor moved the base URL into configuration, and services now call `https://api.example.com/transfers` while the app itself is served from `https://app.example.com`. Every `POST`, `PUT` and `DELETE` starts failing with `403`, and the network tab shows the cookie present but no `X-XSRF-TOKEN` header. ## Why the header disappeared Angular's built-in XSRF interceptor, registered by `provideHttpClient()`, decides per request: 1. Skip `GET` and `HEAD`. 2. Resolve the request URL against the page's current location and compare the two **origins**. An origin is scheme, host and port together, so a different subdomain or even a different port, such as `localhost:4200` versus `localhost:8080`, is a different origin. 3. Only when the origins are equal, read the token cookie and add the header. `api.example.com` and `app.example.com` are different hosts, so step 2 skips the request. This is a **deliberate safety rule**: if Angular attached the token to every absolute URL, any request to a third-party service would hand that service your XSRF token. ## Fix 1: make the API same-origin The most robust fix removes the cross-origin hop: - In production, route a path on the app's host, such as `/api`, to the backend with a reverse proxy or gateway. - In development, use the Angular dev server's proxy support to forward `/api` to the local backend. - Call relative URLs such as `/api/transfers` from the services. Now the interceptor sees a same-origin request and adds the header, CORS preflights disappear, and cookies are first-party. ## Fix 2: add the header for one trusted origin If the API must stay on its own origin, you add the header yourself, but only for that origin: - Read the token through the injectable **`HttpXsrfTokenExtractor`**, the same abstraction the built-in interceptor uses, instead of parsing cookies by hand. - Compare the request's origin with an **exact** allow-listed origin, never with a prefix or `includes` check that `https://api.example.com.evil.test` could satisfy. - Leave the request alone when the token is missing, and do not overwrite a header that is already set. Two conditions outside Angular must also hold: - The token cookie must be **visible to script on the app's host**. A cookie set by `api.example.com` for its own host does not appear in `document.cookie` on `app.example.com`; it has to be issued for a domain both hosts share. - The API's **CORS** configuration must allow the `X-XSRF-TOKEN` request header and credentialed requests, and the client must send credentials, or the cookie that authenticates the request is not sent at all. ## How to confirm the diagnosis 1. In the browser's network panel, check a failing `POST`: the cookie is present on the page, the `X-XSRF-TOKEN` request header is absent. 2. Compare the request URL's scheme, host and port with the page's address bar; any difference means cross-origin. 3. Send the same request to a relative URL on a same-origin proxy and confirm the header appears. ## What not to do | Tempting change | Why it is wrong | | :--- | :--- | | `withNoXsrfProtection()` so the requests "go through" | removes the client half entirely; the backend check still fails or, if also disabled, the API is unprotected | | Adding the header for every absolute URL | leaks the token to any origin the app ever calls | | Disabling the backend's CSRF check for the API host | trades a 403 for a real vulnerability | ## Which Angular version you are on matters In Angular 22.2 the rule is a true origin comparison, so a same-origin absolute URL such as `https://app.example.com/api/x` does get the header. Older releases skipped **every** absolute URL, even same-origin ones, and some older patch lines did not recognise protocol-relative URLs such as `//api.example.com/x` as absolute. If a codebase on an old version shows the header missing even for its own host, that is the reason; upgrading, or using relative URLs, fixes it. ## A related trap: rewriting URLs in an interceptor If, instead of calling absolute URLs in services, the app keeps relative URLs and a custom interceptor prepends the API origin, the outcome depends on interceptor order. `provideHttpClient()` registers its XSRF interceptor ahead of those added with `withInterceptors`, so it sees the relative URL, treats it as same-origin and adds the header before the URL is rewritten. The token then travels to the API origin, intended or not, which is one more reason to make that decision explicitly.
- The dev build calls http://localhost:8080/api while the app runs on http://localhost:4200. Why is the header missing locally?An origin includes the port, so the two localhost addresses are different origins and the interceptor skips the request exactly as it would in production. Proxying `/api` through the dev server keeps requests same-origin, so the header appears without special code.
- Why compare the full origin in a custom interceptor instead of checking that the URL starts with the API address?A prefix check such as `startsWith('https://api.example.com')` also matches `https://api.example.com.attacker.test`, which would receive the token. Parsing the URL and comparing its `origin` exactly to the allow-listed value cannot be fooled that way.
saying these in an interview costs you the question
- HttpClient failing to send the header to another origin is an Angular bug.
- withNoXsrfProtection is the right fix when the API moves to its own host.
- The interceptor sends the token to any https URL the app calls.
- Different ports on localhost still count as the same origin.
- A startsWith check on the URL is enough to allow-list the API.