Majoritatea aplicațiilor folosesc două token-uri. Un access token scurt (câteva minute) însoțește fiecare request. Un refresh token lung permite obținerea unui access token nou fără ca utilizatorul să se autentifice din nou. Partea grea nu e să atașezi un header, ci momentul în care access token-ul expiră în timp ce cinci request-uri sunt deja în zbor.
Pagina construiește pas cu pas un interceptor funcțional care atașează token-ul, la 401 face un singur refresh, partajat de toate cererile care așteaptă, și apoi le reia. Vezi și unde e bine să ții token-urile și ce faci când refresh-ul eșuează.
Fluxul complet
Access token-ul circulă în header-ul Authorization: Bearer …. Când serverul răspunde 401, aplicația cere un token nou de la endpoint-ul de refresh. Refresh token-ul nu e trimis din JavaScript: stă într-un cookie HttpOnly, trimis automat de browser (withCredentials). Apoi request-ul inițial se repetă cu token-ul nou. Pentru utilizator, totul e invizibil.
browser API auth server
│ GET /orders (Bearer A1) │ │
│────────────────────────────▶│ A1 expirat → 401 │
│◀────────────────────────────│ │
│ POST /auth/refresh (cookie HttpOnly cu refresh token) │
│─────────────────────────────────────────────────────────────────▶│
│◀─────────────────────────────────────────── { accessToken: A2 } │
│ GET /orders (Bearer A2) │ │
│────────────────────────────▶│ 200 │Interceptorul: un singur refresh în zbor
Trei detalii fac diferența:
- Interceptorul atașează token-ul doar pentru URL-urile API-ului tău, ca să nu-l trimiți la terți.
- Cererea de refresh e marcată cu un
HttpContextToken(SKIP_AUTH), ca interceptorul să nu se intercepteze singur la infinit (vezi HttpClient & interceptori). - Sesiunea ține refresh-ul curent într-un câmp. Primul
401îl pornește, iar toate celelalte se abonează la același Observable (shareReplay(1)).finalizeeliberează câmpul la final.
Mai e un caz fin: un request poate primi 401 după ce altul a terminat deja refresh-ul. Atunci token-ul curent diferă de cel folosit, deci doar reîncerci, fără încă un refresh.
import { HttpContextToken, HttpErrorResponse, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
/** Cererile marcate nu primesc token și nu declanșează refresh (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); // doar API-ul tău!
const used = session.token();
return next(withToken(req, used)).pipe(
catchError((err: unknown) => {
if (!(err instanceof HttpErrorResponse) || err.status !== 401) return throwError(() => err);
// alt request a reîmprospătat deja token-ul cât timp ăsta era în zbor → doar reîncercăm
if (session.token() && session.token() !== used) return next(withToken(req, session.token()));
return session.refresh().pipe(switchMap((fresh) => next(withToken(req, fresh))));
}),
);
};- Apasă 1, apoi 2. Jurnalul arată ordinea exactă a cererilor.
Demo-ul are propriul HttpClient, creat cu createEnvironmentInjector([provideHttpClient(withInterceptors([…]))], …). Interceptorii lui (auth + un „server” simulat, pus ultimul în lanț) nu ating restul aplicației.
Varianta naivă și de ce strică
Dacă fiecare 401 își face propriul refresh, numărul de refresh-uri crește cu numărul de request-uri paralele. Multe servere rotesc refresh token-ul (fiecare refresh îl invalidează pe precedentul), deci refresh-urile concurente se anulează între ele. Unele detectează chiar „refolosirea” unui refresh token vechi ca atac și închid toată sesiunea.
// ✗ Varianta naivă: fiecare 401 își face propriul refresh
catchError((err) =>
err.status === 401
? http.post('/auth/refresh', null).pipe(switchMap((r) => next(withToken(req, r.accessToken))))
: throwError(() => err),
);
// 5 request-uri paralele cu token expirat → 5 refresh-uri.
// Cu „refresh token rotation”, fiecare refresh îl invalidează pe precedentul → unele request-uri pică din nou,
// iar unele servere detectează refolosirea și închid toată sesiunea.Unde ții token-urile
Orice cod JavaScript din pagina ta poate citi localStorage, deci și un script injectat printr-un XSS (vezi Securitate). Un cookie HttpOnly nu poate fi citit din JavaScript, dar e trimis automat, așa că ai nevoie de SameSite și protecție XSRF. Compromisul obișnuit: access token-ul scurt în memorie (un semnal), refresh token-ul în cookie HttpOnly. Varianta cea mai sigură e un BFF (backend-for-frontend): token-urile OAuth rămân pe server, iar browserul are doar un cookie de sesiune.
| Dacă aplicația are un XSS | nu poate fi citit direct, dar codul injectat poate face request-uri cât e pagina deschisă |
|---|---|
| La reîncărcarea paginii | se pierde → refresh token (cookie) la pornire |
| CSRF | nu e trimis automat → fără CSRF |
| Mai multe tab-uri | fiecare tab face refresh separat |
recomandat Bun pentru access token-ul scurt, combinat cu refresh token în cookie HttpOnly.
Pornire, guard-uri și tab-uri
La pornirea aplicației, access token-ul din memorie lipsește. Un provideAppInitializer încearcă un refresh înainte de prima rută, așa că utilizatorul autentificat nu vede pagina de login nici măcar o clipă. Starea de autentificare e un semnal, iar guard-ul funcțional îl citește și redirecționează cu createUrlTree (vezi Guards & resolvers). Logout-ul trebuie propagat în toate tab-urile. Un BroadcastChannel face asta cu câteva linii (vezi și Angular Elements).
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideAppInitializer(() => {
// la pornire: există un refresh token în cookie? obținem un access token înainte de prima pagină
const session = inject(AuthSession);
return firstValueFrom(session.refresh()).catch(() => undefined);
}),
],
};Când DA, când NU
- Backend-ul tău emite token-urile (JWT propriu) și ai nevoie doar de atașare + refresh.
- Vrei control complet asupra cozii de request-uri și a cazurilor de eroare.
- Ai un BFF și interceptorul doar adaugă
withCredentialsși tratează401→ login.
- OAuth 2 / OpenID Connect cu un provider extern (Entra ID, Auth0, Keycloak): folosește o bibliotecă OIDC certificată, care implementează PKCE, rotația și validările corect. Flow-ul în sine, pas cu pas și cu criptografie reală, e pe pagina despre <a href="/oauth-pkce">OAuth 2.1 și PKCE</a>.
- Aplicații cu cerințe mari de securitate: alege BFF în loc de token-uri în browser.
- Sesiuni clasice cu cookie, fără token-uri: îți trebuie doar XSRF și tratarea lui
401.
Capcane frecvente
- Token trimis oriunde: un interceptor fără filtru pe URL atașează
Authorizationși la request-uri către CDN-uri sau API-uri terțe. - Interceptorul interceptează refresh-ul: un
401la refresh pornește alt refresh, și tot așa. Marchează cererea cuHttpContextToken. - Refresh-ul nu e partajat: N request-uri înseamnă N refresh-uri (vezi demo-ul).
- Retry pe orice eroare: doar
401justifică un refresh.403înseamnă „nu ai voie”, iar un refresh nu schimbă nimic. - Logout care nu curăță tot: store-uri, cache-uri
shareReplay, date înlocalStorage. Utilizatorul următor de pe același calculator le vede. - Decodarea JWT-ului „pentru securitate” în frontend: e doar informativă (afișezi numele). Orice verificare de drepturi se face pe server.
Fișă rapidă
TOKEN-URI access token scurt (minute) în MEMORIE · refresh token lung în cookie HttpOnly + Secure + SameSite
sau BFF: browserul are doar un cookie de sesiune, token-urile stau pe server
INTERCEPTOR doar pentru URL-urile API-ului tău · SKIP_AUTH (HttpContextToken) pentru login/refresh
401 un singur refresh în zbor (shareReplay + finalize) · retry cu token-ul nou
dacă token-ul s-a schimbat între timp → doar retry · refresh eșuat → logout + redirect
PORNIRE provideAppInitializer: încearcă un refresh înainte de prima rută
GUARD CanActivateFn pe semnalul isLoggedIn → createUrlTree(['/login'], { returnUrl })
TAB-URI BroadcastChannel / evenimentul storage pentru logout sincron
NU token în localStorage fără CSP · refresh pe fiecare 401 separat · interceptor care intercepte și refresh-ul