~9 min de lecture
CSRF en Angular : les 4 règles silencieuses qui décident si ton header X-XSRF-TOKEN part (ou pas)
Vendredi, 17h. Tu déploies. Tous les POST renvoient 403 Forbidden, les GET passent nickel. Dans les DevTools, le cookie XSRF-TOKEN est là, posé par le backend ; dans l'onglet Network, le header X-XSRF-TOKEN que ton serveur exige n'apparaît sur aucune requête. Angular a décidé, en silence, de ne pas l'envoyer.
Ce n'est pas un bug. C'est un interceptor livré actif par défaut avec provideHttpClient(), qui applique 4 règles que la doc expédie en une phrase. Tant que tu ne les connais pas, chaque 403 est une séance de divination. Une fois que tu les as vues dans le code source, le diagnostic prend dix secondes.
Bonus : la règle sur l'URL a valu à Angular une CVE fin 2025, puis a changé de sémantique, via de simples montées de patch. Si ton package.json traîne sous la 19.2.16, la 20.3.14 ou la 21.0.1, lis jusqu'au bout.
Le mécanisme que tu utilises sans le savoir
Une attaque CSRF (Cross-Site Request Forgery), c'est quand un site malveillant déclenche, depuis le navigateur de ton utilisateur, une requête vers TON backend : les cookies de session partent automatiquement, la requête arrive authentifiée. L'attaquant peut faire envoyer les cookies, mais pas les lire depuis son origine (le triplet protocole + domaine + port : https://app.example.com et https://api.example.com sont deux origines distinctes).
La défense classique exploite cette asymétrie, c'est le pattern cookie-to-header :
- Le backend pose un cookie contenant un token aléatoire, lisible par JavaScript.
- Le front recopie sa valeur dans un header HTTP sur chaque requête qui modifie des données.
- Le backend vérifie que le header correspond au cookie ; l'attaquant ne peut pas lire le cookie, donc pas fabriquer le header.
Angular implémente le point 2 pour toi, et l'écrit XSRF : CSRF et XSRF sont deux graphies du même sigle, d'où XSRF-TOKEN et X-XSRF-TOKEN. Dès que provideHttpClient() est dans ton app.config.ts, xsrfInterceptorFn s'enregistre tout seul :
// Inside @angular/common/http (simplified): provideHttpClient() always registers it
const providers = [
HttpClient,
// ...
{ provide: HTTP_INTERCEPTOR_FNS, useValue: xsrfInterceptorFn, multi: true },
];
Par défaut : cookie XSRF-TOKEN, header X-XSRF-TOKEN. Si ton backend utilise pile ces noms (Laravel, notamment), tout marche sans config. Jusqu'au jour où une des 4 règles te coupe le header.
Règle 1 : GET et HEAD ne portent jamais le token
Première condition :
if (!inject(XSRF_ENABLED) || req.method === 'GET' || req.method === 'HEAD') {
return next(req);
}
Un GET n'est pas censé modifier l'état : l'interceptor pose le header sur toute méthode SAUF GET et HEAD, en pratique POST, PUT, PATCH et DELETE. Si ton backend exige le token sur un GET, Angular ne te le donnera jamais : corrige le backend, ou assume un interceptor maison (fin d'article).
Règle 2 : l'URL décide, et cette règle a changé deux fois en 21.x
La règle du vendredi 17h. Jusqu'à la 21.0.0 incluse (Angular 17 et 18 compris), le code était :
// Up to 21.0.0, and any version without the late-2025 patches
const lcUrl = req.url.toLowerCase();
if (/* ... */ lcUrl.startsWith('http://') || lcUrl.startsWith('https://')) {
return next(req); // absolute URL: no XSRF header, silently
}
Toute URL absolue était exclue, même quand elle pointait sur TA propre origine. Ce service raisonnable ne porte donc jamais le header :
// environment.ts
export const environment = {
apiUrl: 'https://app.example.com/api',
};
// order-api.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../environments/environment';
@Injectable({ providedIn: 'root' })
export class OrderApi {
private readonly http = inject(HttpClient);
create(order: CreateOrderDto) {
// Same origin, but absolute: on these versions, no XSRF header. 403.
return this.http.post<Order>(`${environment.apiUrl}/orders`, order);
}
}
La même requête en URL relative, elle, le porte. Même origine, même backend, deux comportements. Le contournement sur ces versions : des URL relatives partout, plus un reverse proxy (un serveur devant ton front qui relaie /api vers le backend). Côté code, puis côté proxy de dev de l'Angular CLI (proxy.conf.json, passé à ng serve --proxy-config) :
// order-api.ts, after: relative URL, the header is attached on every version
create(order: CreateOrderDto) {
return this.http.post<Order>('/api/orders', order);
}
{ "/api": { "target": "http://localhost:8080" } }
Fin 2025, en 21.x, la règle a bougé deux fois coup sur coup : un correctif de sécurité (la CVE-2025-66035, détaillée plus bas) a d'abord élargi le test sans changer la logique, puis la 21.0.3 (décembre 2025) l'a réécrite en vraie comparaison d'origines :
// 21.0.3+, 20.3.34+ and all 22.x (simplified: the real code wraps this
// in a try/catch, an unparseable URL means no header)
const { origin: locationOrigin } = new URL(inject(PlatformLocation).href);
const { origin: requestOrigin } = new URL(req.url, locationOrigin);
if (locationOrigin !== requestOrigin) {
return next(req); // only genuinely cross-origin requests are skipped
}
Une URL absolue same-origin (même origine que la page) reçoit maintenant le token ; seule une requête cross-origin (origine différente) est exclue, et c'est voulu : la section CVE, plus bas, montre ce qui arrive quand le token part ailleurs. Ce comportement est présent dans toutes les 22.x, rétroporté en 20.3.34 (octobre 2026) ; Angular 19 garde le test par chaîne du correctif CVE, les 17/18 n'ont rien reçu.
Le changement arrive donc par une simple montée de PATCH (21.0.2 vers 21.0.3, 20.3.33 vers 20.3.34) : X-XSRF-TOKEN apparaît sur des requêtes où il n'était jamais parti. En général, c'est ce que tu voulais, ton environment.apiUrl absolu fonctionne enfin ; mais une gateway same-origin qui rejette les headers inconnus peut transformer un npm update en nouveau 403. Vérifie tes POST après la montée.
Règle 3 : un cookie HttpOnly rend le token invisible
Le réflexe standard, c'est HttpOnly sur tous les cookies. Pour le cookie de session : oui, toujours. Pour le cookie XSRF : surtout pas.
Tout le pattern repose sur le fait que le front LISE le cookie. L'extracteur d'Angular parse littéralement document.cookie :
// Inside HttpXsrfCookieExtractor (simplified)
getToken(): string | null {
const cookieString = this.doc.cookie || '';
return parseCookieValue(cookieString, this.cookieName);
}
Un cookie HttpOnly n'apparaît pas dans document.cookie : l'extracteur renvoie null, l'interceptor ne pose rien, toujours sans un mot dans la console. Le symptôme est identique à celui de la règle 2, et le diagnostic tient à une colonne. Regarde "HttpOnly" dans l'onglet Application des DevTools, ou tape document.cookie dans la console : token absent = coupable trouvé.
Même cause pendant le rendu serveur (SSR) : pas de cookies de navigateur, le code court-circuite et getToken() renvoie null. Aucune requête SSR ne porte le header ; un backend qui l'exige pendant le rendu serveur impose une transmission manuelle, pas un réglage Angular.
Un token lisible par JavaScript reste un compromis assumé : un attaquant qui exécute du JS chez toi, c'est une XSS, et le CSRF est le cadet de tes soucis.
Règle 4 : un header déjà posé n'est jamais écrasé
Dernière condition, un ! facile à rater devant req.headers.has(...) :
if (token != null && !req.headers.has(headerName)) {
req = req.clone({ headers: req.headers.set(headerName, token) });
}
Si un X-XSRF-TOKEN est déjà présent, l'interceptor n'y touche pas. Piégeux si un interceptor maison ou un SDK pose un header vide ou périmé : il part tel quel et le backend répond 403, token pourtant valide dans le cookie. En debug, regarde la valeur du header, pas seulement sa présence.
Le bug inverse : le token qui partait trop (CVE-2025-66035)
Les règles ci-dessus retiennent le header. Fin 2025, on a découvert l'inverse : un cas où il partait chez l'attaquant.
Relis le test d'avant les correctifs : une URL protocol-relative comme //evil.com/collect (une URL en // qui reprend le protocole de la page) ne commence ni par http:// ni par https://. Traitée comme relative, elle recevait ton token. Une URL influencée par une donnée externe (config, réponse d'API, paramètre) suffisait à faire fuir le token vers une origine arbitraire.
C'est la CVE-2025-66035, publiée le 26 novembre 2025, corrigée en 19.2.16, 20.3.14 et 21.0.1 par un test plus strict (/^(?:https?:)?\/\//i, toute URL absolue ou protocol-relative exclue). Deux nuances avant de classer le dossier :
- Angular 17 et 18 sont déclarés affectés et n'ont reçu aucun correctif : hors support, la sortie est une montée de majeure.
- La regex du correctif reste un test de chaîne, qui n'interprète pas les URL comme le navigateur (le standard URL traite
/\comme//pour http et https, la regex ne l'exclut pas). La comparaison d'origines rejette ce cas par construction : vise-la si tu peux.
Action concrète : npm ls @angular/common. Cibles : 20.3.34+, 21.0.3+ ou n'importe quelle 22.x. Sur Angular 19, la 19.2.16 est le minimum pour la CVE, mais la branche est hors support et n'a que la regex : planifie la montée.
Adapter la config à ton backend (et le piège Spring Security 6)
Des noms différents côté serveur ? Ne réécris pas d'interceptor : la config existe.
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
// Django defaults
withXsrfConfiguration({
cookieName: 'csrftoken',
headerName: 'X-CSRFToken',
}),
),
],
};
Laravel colle aux défauts d'Angular : rien à faire. Django : config ci-dessus. Spring Security, lui, tend un piège : les noms collent (CookieCsrfTokenRepository.withHttpOnlyFalse() pose bien XSRF-TOKEN et attend X-XSRF-TOKEN), mais depuis Spring Security 6 le handler par défaut attend une valeur encodée et masquée par XOR, pas la valeur brute qu'Angular recopie. Résultat : 403 avec header PRÉSENT et cookie valide, la variante la plus déroutante du vendredi 17h. La doc Spring est explicite : il faut un CsrfTokenRequestHandler dédié aux SPA. En 6.x, elle fournit une classe SpaCsrfTokenRequestHandler à recopier ; csrf.spa() n'arrive qu'en Spring Security 7. Spring Security 5 : rien à faire.
Protection par un autre canal, synchronizer token (un token injecté par le serveur dans le HTML, sans cookie) ou header custom géré ailleurs ? Désactive la mécanique avec withNoXsrfProtection() plutôt que de la laisser tourner pour rien.
Le cas cross-origin assumé
Ton API sur api.example.com, ton front sur app.example.com : deux origines, l'interceptor d'Angular ne t'aidera jamais, volontairement. Si ton backend attend quand même le cookie-to-header, il te faut trois choses.
Un : un cookie posé avec Domain=.example.com, sinon document.cookie ne le voit pas depuis app.. Deux : withCredentials: true sur TOUTES les requêtes vers l'API, GET compris, sans quoi le navigateur ignore les Set-Cookie cross-origin et le cookie du token n'existe jamais. Trois : côté serveur, sur TOUTES les réponses de l'API, Access-Control-Allow-Origin: https://app.example.com (l'origine exacte, jamais *) et Access-Control-Allow-Credentials: true ; et dans la réponse à la requête preflight (le OPTIONS envoyé avant la vraie requête), ton header listé dans Access-Control-Allow-Headers.
Côté Angular, un interceptor maison :
import { PlatformLocation } from '@angular/common';
import { HttpInterceptorFn, HttpXsrfTokenExtractor } from '@angular/common/http';
import { inject } from '@angular/core';
const API_ORIGIN = 'https://api.example.com';
export const apiXsrfInterceptor: HttpInterceptorFn = (req, next) => {
let requestOrigin: string;
try {
requestOrigin = new URL(req.url, inject(PlatformLocation).href).origin;
} catch {
return next(req);
}
if (requestOrigin !== API_ORIGIN) {
return next(req);
}
// Credentials on every API call, so the session cookie and the
// Set-Cookie carrying the token actually cross the origin boundary.
req = req.clone({ withCredentials: true });
const token = inject(HttpXsrfTokenExtractor).getToken();
const mutating = req.method !== 'GET' && req.method !== 'HEAD';
if (mutating && token !== null && !req.headers.has('X-XSRF-TOKEN')) {
req = req.clone({ headers: req.headers.set('X-XSRF-TOKEN', token) });
}
return next(req);
};
Remarque le garde-fou : une comparaison d'origine stricte, celle qu'Angular a fini par adopter, pas un startsWith. Un test de préfixe laisse passer https://api.example.com.evil.com ou https://api.example.com@evil.com : ton token part chez l'attaquant, le modèle de menace de la CVE. Un interceptor qui envoie le token sur "tout sauf GET" est pire encore.
Récap actionnable
provideHttpClient()active la protection par défaut : cookieXSRF-TOKENrecopié dansX-XSRF-TOKEN, toute méthode sauf GET et HEAD.- Header absent ? Dans l'ordre : méthode (GET/HEAD = jamais), URL (absolue = ignorée avant 21.0.3 / 20.3.34 et sur toute 19.x ; cross-origin = ignorée avec la comparaison d'origines), cookie HttpOnly, header déjà posé (jamais écrasé, même vide).
- Header présent mais 403 quand même ? Pense à Spring Security 6+, qui attend une valeur encodée : handler SPA côté serveur (
csrf.spa()à partir de la 7). - URL relatives + proxy : la seule forme qui garantit le header sur toutes les versions.
- La comparaison d'origines arrive par montée de patch (21.0.3, 20.3.34) : le header peut apparaître là où il ne partait pas. Vérifie tes POST.
- Sous 19.2.16 / 20.3.14 / 21.0.1, une URL protocol-relative fait fuir ton token (CVE-2025-66035). Angular 17/18 : affectés, jamais patchés, montée de majeure obligatoire.
- Backend à noms custom :
withXsrfConfiguration({ cookieName, headerName }). Autre mécanisme :withNoXsrfProtection(). - API cross-origin : interceptor maison avec comparaison d'origine stricte et
withCredentialssur tous les appels, jamais un envoi générique.
Le header qui ne part pas et le token qui part trop sont les deux faces d'une même pièce : 4 règles dans un interceptor minuscule. Maintenant que tu les as lues, ton prochain 403 ne durera que le temps d'un coup d'œil à l'onglet Network.