La plupart des applications utilisent deux tokens. Un access token de courte durée (quelques minutes) accompagne chaque requête. Un refresh token de longue durée permet d’obtenir un nouvel access token sans que l’utilisateur se reconnecte. Le plus dur n’est pas d’ajouter un en-tête, mais le moment où l’access token expire alors que cinq requêtes sont déjà en cours.
Cette page construit, pas à pas, un interceptor fonctionnel qui ajoute le token, fait sur un 401 un seul refresh partagé par toutes les requêtes en attente, puis les rejoue. Vous verrez aussi où il est judicieux de conserver les tokens et que faire quand le refresh échoue.
Le flux complet
L’access token voyage dans l’en-tête Authorization: Bearer …. Quand le serveur répond 401, l’application demande un nouveau token à l’endpoint de refresh. Le refresh token n’est pas envoyé depuis JavaScript : il se trouve dans un cookie HttpOnly, que le navigateur envoie automatiquement (withCredentials). Ensuite, la requête d’origine est rejouée avec le nouveau token. Pour l’utilisateur, tout est invisible.
browser API auth server
│ GET /orders (Bearer A1) │ │
│────────────────────────────▶│ A1 expiré → 401 │
│◀────────────────────────────│ │
│ POST /auth/refresh (cookie HttpOnly avec refresh token) │
│─────────────────────────────────────────────────────────────────▶│
│◀─────────────────────────────────────────── { accessToken: A2 } │
│ GET /orders (Bearer A2) │ │
│────────────────────────────▶│ 200 │L’Interceptor : un seul refresh en cours
Trois détails font la différence :
- L’interceptor ajoute le token uniquement aux URLs de votre propre API, pour ne pas l’envoyer à des tiers.
- La requête de refresh est marquée avec un
HttpContextToken(SKIP_AUTH), pour que l’interceptor ne s’intercepte pas lui-même à l’infini (voir HttpClient & interceptors). - La session garde le refresh courant dans un champ. Le premier
401le démarre, et tous les autres s’abonnent au même Observable (shareReplay(1)).finalizevide le champ à la fin.
Il y a un cas subtil : une requête peut recevoir un 401 après qu’une autre a déjà terminé le refresh. Le token courant diffère alors de celui utilisé, donc elle se contente de réessayer, sans autre refresh.
import { HttpContextToken, HttpErrorResponse, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
/** Les requêtes marquées ne reçoivent pas de token et ne déclenchent pas de refresh (p. ex. login, refresh). */
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
const withToken = (req: HttpRequest<unknown>, token: string | null) =>
token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const session = inject(AuthSession);
if (req.context.get(SKIP_AUTH) || !req.url.startsWith('/api/')) return next(req); // uniquement votre API !
const used = session.token();
return next(withToken(req, used)).pipe(
catchError((err: unknown) => {
if (!(err instanceof HttpErrorResponse) || err.status !== 401) return throwError(() => err);
// une autre requête a déjà renouvelé le token pendant que celle-ci était en cours → on réessaie simplement
if (session.token() && session.token() !== used) return next(withToken(req, session.token()));
return session.refresh().pipe(switchMap((fresh) => next(withToken(req, fresh))));
}),
);
};- Cliquez sur 1, puis sur 2. Le journal affiche l’ordre exact des requêtes.
La démo a son propre HttpClient, créé avec createEnvironmentInjector([provideHttpClient(withInterceptors([…]))], …). Ses interceptors (auth + un « serveur » simulé, placé en dernier dans la chaîne) n’affectent pas le reste de l’application.
La variante naïve et pourquoi elle casse
Si chaque 401 fait son propre refresh, le nombre de refreshs croît avec le nombre de requêtes parallèles. De nombreux serveurs font tourner le refresh token (chaque refresh invalide le précédent) : les refreshs concurrents s’annulent donc mutuellement. Certains considèrent même la réutilisation d’un ancien refresh token comme une attaque et ferment toute la session.
// ✗ La version naïve : chaque 401 fait son propre refresh
catchError((err) =>
err.status === 401
? http.post('/auth/refresh', null).pipe(switchMap((r) => next(withToken(req, r.accessToken))))
: throwError(() => err),
);
// 5 requêtes parallèles avec un token expiré → 5 refreshs.
// Avec la « rotation des refresh tokens », chaque refresh invalide le précédent → certaines requêtes échouent à nouveau,
// et certains serveurs détectent la réutilisation comme une attaque et ferment toute la session.Où conserver les tokens
Tout code JavaScript de votre page peut lire localStorage, y compris un script injecté via une XSS (voir Sécurité). Un cookie HttpOnly ne peut pas être lu depuis JavaScript, mais il est envoyé automatiquement, donc il faut SameSite et une protection XSRF. Le compromis habituel : l’access token de courte durée en mémoire (un signal), le refresh token dans un cookie HttpOnly. L’option la plus sûre est un BFF (backend-for-frontend) : les tokens OAuth restent sur le serveur, et le navigateur n’a qu’un cookie de session.
| Si l’application a une faille XSS | il ne peut pas être lu directement, mais du code injecté peut faire des requêtes tant que la page est ouverte |
|---|---|
| Au rechargement de la page | perdu → refresh token (cookie) au démarrage |
| CSRF | non envoyé automatiquement → pas de CSRF |
| Plusieurs onglets | chaque onglet fait son propre refresh |
recommandé Convient pour l’access token de courte durée, combiné à un refresh token dans un cookie HttpOnly.
Démarrage, Guards et onglets
Au démarrage de l’application, l’access token en mémoire est absent. Un provideAppInitializer tente un refresh avant la première route, pour qu’un utilisateur connecté ne voie pas la page de connexion, même un instant. L’état d’authentification est un signal, et le Guard fonctionnel le lit et redirige avec createUrlTree (voir Guards & resolvers). La déconnexion doit être propagée à tous les onglets. Un BroadcastChannel le fait en quelques lignes (voir aussi Angular Elements).
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideAppInitializer(() => {
// au démarrage : y a-t-il un refresh token dans le cookie ? on obtient un access token avant la première page
const session = inject(AuthSession);
return firstValueFrom(session.refresh()).catch(() => undefined);
}),
],
};Quand OUI, quand NON
- Votre backend émet les tokens (son propre JWT) et il ne vous faut que l’ajout + le refresh.
- Vous voulez un contrôle total sur la file de requêtes et les cas d’erreur.
- Vous avez un BFF, et l’interceptor ajoute seulement
withCredentialset gère401→ connexion.
- OAuth 2 / OpenID Connect avec un fournisseur externe (Entra ID, Auth0, Keycloak) : utilisez une bibliothèque OIDC certifiée, qui implémente correctement PKCE, la rotation et les validations. Le flow lui-même, étape par étape et avec une vraie cryptographie, est sur la page <a href="/oauth-pkce">OAuth 2.1 et PKCE</a>.
- Applications à fortes exigences de sécurité : choisissez un BFF plutôt que des tokens dans le navigateur.
- Sessions classiques par cookie, sans tokens : il ne vous faut que XSRF et la gestion de
401.
Pièges fréquents
- Token envoyé partout : un interceptor sans filtre d’URL ajoute
Authorizationaussi aux requêtes vers des CDN ou des API tierces. - L’interceptor intercepte le refresh : un
401sur le refresh déclenche un autre refresh, et ainsi de suite. Marquez la requête avec unHttpContextToken. - Le refresh n’est pas partagé : N requêtes donnent N refreshs (voir la démo).
- Réessayer sur n’importe quelle erreur : seul un
401justifie un refresh. Un403signifie « vous n’avez pas le droit », et un refresh n’y change rien. - Une déconnexion qui ne nettoie pas tout : stores, caches
shareReplay, données danslocalStorage. Le prochain utilisateur du même ordinateur les voit. - Décoder le JWT « par sécurité » dans le frontend : il n’est qu’informatif (vous affichez le nom). Toute vérification de permission se fait sur le serveur.
Aide-mémoire
TOKENS access token de courte durée (minutes) en MÉMOIRE · refresh token de longue durée dans un cookie HttpOnly + Secure + SameSite
ou BFF : le navigateur n’a qu’un cookie de session, les tokens restent sur le serveur
INTERCEPTOR uniquement pour les URLs de votre API · SKIP_AUTH (HttpContextToken) pour login/refresh
401 un seul refresh en cours (shareReplay + finalize) · nouvelle tentative avec le nouveau token
si le token a changé entre-temps → simplement réessayer · refresh échoue → déconnexion + redirection
DÉMARRAGE provideAppInitializer : tenter un refresh avant la première route
GUARD CanActivateFn on the isLoggedIn signal → createUrlTree(['/login'], { returnUrl })
ONGLETS BroadcastChannel / l’événement storage pour une déconnexion synchronisée
À ÉVITER token dans localStorage sans CSP · un refresh distinct à chaque 401 · un interceptor qui intercepte aussi le refresh