Most applications use two tokens. A short-lived access token (a few minutes) accompanies every request. A long-lived refresh token lets you obtain a new access token without the user logging in again. The hard part is not attaching a header, but the moment the access token expires while five requests are already in flight.
The page builds, step by step, a functional interceptor that attaches the token, on a 401 makes a single refresh shared by all the waiting requests, and then replays them. You will also see where it is good to keep the tokens and what to do when the refresh fails.
The full flow
The access token travels in the Authorization: Bearer … header. When the server answers 401, the application asks the refresh endpoint for a new token. The refresh token is not sent from JavaScript: it sits in an HttpOnly cookie, which the browser sends automatically (withCredentials). Then the original request is repeated with the new token. For the user, everything is invisible.
browser API auth server
│ GET /orders (Bearer A1) │ │
│────────────────────────────▶│ A1 expired → 401 │
│◀────────────────────────────│ │
│ POST /auth/refresh (HttpOnly cookie with refresh token) │
│─────────────────────────────────────────────────────────────────▶│
│◀─────────────────────────────────────────── { accessToken: A2 } │
│ GET /orders (Bearer A2) │ │
│────────────────────────────▶│ 200 │The interceptor: one refresh in flight
Three details make the difference:
- The interceptor attaches the token only to the URLs of your own API, so you don’t send it to third parties.
- The refresh request is marked with an
HttpContextToken(SKIP_AUTH), so the interceptor does not intercept itself endlessly (see HttpClient & interceptors). - The session keeps the current refresh in a field. The first
401starts it, and all the others subscribe to the same Observable (shareReplay(1)).finalizeclears the field at the end.
There is one subtle case: a request can receive a 401 after another one has already finished the refresh. Then the current token differs from the one used, so it simply retries, without another refresh.
import { HttpContextToken, HttpErrorResponse, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
/** Marked requests get no token and do not trigger a refresh (e.g. 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); // only your API!
const used = session.token();
return next(withToken(req, used)).pipe(
catchError((err: unknown) => {
if (!(err instanceof HttpErrorResponse) || err.status !== 401) return throwError(() => err);
// another request already refreshed the token while this one was in flight → we just retry
if (session.token() && session.token() !== used) return next(withToken(req, session.token()));
return session.refresh().pipe(switchMap((fresh) => next(withToken(req, fresh))));
}),
);
};- Press 1, then 2. The log shows the exact order of the requests.
The demo has its own HttpClient, created with createEnvironmentInjector([provideHttpClient(withInterceptors([…]))], …). Its interceptors (auth + a simulated “server”, placed last in the chain) do not touch the rest of the application.
The naive variant and why it breaks
If every 401 makes its own refresh, the number of refreshes grows with the number of parallel requests. Many servers rotate the refresh token (each refresh invalidates the previous one), so concurrent refreshes cancel each other out. Some even treat the reuse of an old refresh token as an attack and close the whole session.
// ✗ The naive version: each 401 makes its own refresh
catchError((err) =>
err.status === 401
? http.post('/auth/refresh', null).pipe(switchMap((r) => next(withToken(req, r.accessToken))))
: throwError(() => err),
);
// 5 parallel requests with an expired token → 5 refreshes.
// With “refresh token rotation”, each refresh invalidates the previous one → some requests fail again,
// and some servers detect the reuse as an attack and close the whole session.Where to keep the tokens
Any JavaScript code on your page can read localStorage, including a script injected through an XSS (see Security). An HttpOnly cookie cannot be read from JavaScript, but it is sent automatically, so you need SameSite and XSRF protection. The usual compromise: the short-lived access token in memory (a signal), the refresh token in an HttpOnly cookie. The safest option is a BFF (backend-for-frontend): the OAuth tokens stay on the server, and the browser has only a session cookie.
| If the application has an XSS | it cannot be read directly, but injected code can make requests while the page is open |
|---|---|
| When the page reloads | lost → refresh token (cookie) at startup |
| CSRF | not sent automatically → no CSRF |
| Several tabs | each tab refreshes separately |
recommended Good for the short-lived access token, combined with a refresh token in an HttpOnly cookie.
Startup, guards and tabs
When the application starts, the access token in memory is missing. A provideAppInitializer tries a refresh before the first route, so a logged-in user does not see the login page even for a moment. The authentication state is a signal, and the functional guard reads it and redirects with createUrlTree (see Guards & resolvers). Logout must be propagated to all tabs. A BroadcastChannel does this in a few lines (see also Angular Elements).
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideAppInitializer(() => {
// on startup: is there a refresh token in the cookie? we get an access token before the first page
const session = inject(AuthSession);
return firstValueFrom(session.refresh()).catch(() => undefined);
}),
],
};When YES, when NO
- Your backend issues the tokens (its own JWT) and you only need attaching + refresh.
- You want full control over the request queue and the error cases.
- You have a BFF, and the interceptor only adds
withCredentialsand handles401→ login.
- OAuth 2 / OpenID Connect with an external provider (Entra ID, Auth0, Keycloak): use a certified OIDC library, which implements PKCE, rotation and the validations correctly. The flow itself, step by step and with real cryptography, is on the page about <a href="/oauth-pkce">OAuth 2.1 and PKCE</a>.
- Applications with high security requirements: choose a BFF instead of tokens in the browser.
- Classic cookie sessions, without tokens: you only need XSRF and handling of
401.
Common pitfalls
- Token sent everywhere: an interceptor without a URL filter attaches
Authorizationto requests to CDNs or third-party APIs too. - The interceptor intercepts the refresh: a
401on the refresh starts another refresh, and so on. Mark the request with anHttpContextToken. - The refresh is not shared: N requests mean N refreshes (see the demo).
- Retry on any error: only a
401justifies a refresh. A403means “you are not allowed”, and a refresh changes nothing. - Logout that doesn’t clean up everything: stores,
shareReplaycaches, data inlocalStorage. The next user of the same computer sees them. - Decoding the JWT “for security” in the frontend: it is only informative (you display the name). Any permission check is done on the server.
Cheat sheet
TOKENS short-lived access token (minutes) in MEMORY · long-lived refresh token in an HttpOnly + Secure + SameSite cookie
or BFF: the browser has only a session cookie, the tokens stay on the server
INTERCEPTOR only for your API's URLs · SKIP_AUTH (HttpContextToken) for login/refresh
401 one refresh in flight (shareReplay + finalize) · retry with the new token
if the token changed in the meantime → just retry · refresh fails → logout + redirect
STARTUP provideAppInitializer: try a refresh before the first route
GUARD CanActivateFn on the isLoggedIn signal → createUrlTree(['/login'], { returnUrl })
TABS BroadcastChannel / the storage event for synchronous logout
NO token in localStorage without CSP · a separate refresh on every 401 · an interceptor that also intercepts the refresh