In an Angular app, how should an auth interceptor refresh an expired access token on a 401 and replay the request without five concurrent failures triggering five refreshes?
answer
- catch the 401, not every error
- one refresh in flight
- replay by calling next again
- the refresh call must bypass auth
basics
~20 sIn the interceptor, catch an HttpErrorResponse with status 401, wait on one shared in-flight refresh Observable, then call next() again with a clone carrying the new token. The refresh request itself skips the interceptor, and a rejected refresh logs the user out.
solid answer
~40 sWrap `next(req)` in `catchError`: for anything other than a 401 `HttpErrorResponse`, rethrow. For a 401, ask the auth service for a new token and `switchMap` into `next(req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } }))` — calling `next()` again replays the request through the rest of the chain. To stop a burst of 401s from starting several refreshes, the service keeps **one in-flight refresh Observable** shared with `shareReplay(1)` and cleared when it finishes, so every waiting request reuses it. The refresh call must **bypass the auth logic**, typically with an `HttpContextToken`; otherwise its own 401 would wait on itself. A refresh the server rejects (401 or 400) should log the user out and rethrow, and `inject()` must be called at the top of the interceptor, not inside the callbacks.
code
ts · 73 linesimport { Injectable, inject } from '@angular/core';
import {
HttpClient,
HttpContext,
HttpContextToken,
HttpErrorResponse,
HttpInterceptorFn,
HttpRequest,
HttpStatusCode,
} from '@angular/common/http';
import { Observable, catchError, finalize, map, shareReplay, switchMap, throwError } from 'rxjs';
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private token: string | null = null;
private refreshInFlight$: Observable<string> | null = null;
accessToken(): string | null {
return this.token;
}
refreshToken(): Observable<string> {
// Every 401 that arrives while a refresh is running shares this one call.
this.refreshInFlight$ ??= this.http
.post<{ accessToken: string }>('/api/auth/refresh', null, {
context: new HttpContext().set(SKIP_AUTH, true),
})
.pipe(
map((res) => (this.token = res.accessToken)),
catchError((err: unknown) => {
// Only a rejected refresh ends the session; network errors are rethrown as-is.
if (
err instanceof HttpErrorResponse &&
(err.status === HttpStatusCode.Unauthorized || err.status === HttpStatusCode.BadRequest)
) {
this.logout();
}
return throwError(() => err);
}),
finalize(() => (this.refreshInFlight$ = null)),
shareReplay(1),
);
return this.refreshInFlight$;
}
logout(): void {
this.token = null;
// navigate to the login page here
}
}
const withToken = (req: HttpRequest<unknown>, token: string | null) =>
token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_AUTH) || !req.url.startsWith('/api/')) {
return next(req);
}
const auth = inject(AuthService); // synchronous: inside the injection context
return next(withToken(req, auth.accessToken())).pipe(
catchError((err: unknown) => {
if (!(err instanceof HttpErrorResponse) || err.status !== HttpStatusCode.Unauthorized) {
return throwError(() => err);
}
// Replay once through the rest of the chain with the new token.
return auth.refreshToken().pipe(switchMap((token) => next(withToken(req, token))));
}),
);
};go deeper
Know the shape: catch a 401, get a new token, retry the request with the new header, and log out if the refresh is rejected.
Explain why the replay uses next() with a clone, why only 401 is handled, and why inject() must happen before any RxJS callback runs.
Design the shared in-flight refresh, the SKIP_AUTH bypass that prevents self-deadlock, single-point logout, and the tests that prove one refresh per burst.
Weigh reactive against proactive refresh, token storage against XSS exposure, and whether a backend-for-frontend with cookie sessions removes the need for client-side token handling.
## The flow in one picture 1. A request goes out with an access token that has just expired. 2. The API answers **401 Unauthorized**; `HttpClient` delivers an `HttpErrorResponse` with `status` 401 on the error channel. 3. The interceptor catches it, obtains a new access token (usually by calling a refresh endpoint that uses a long-lived, httpOnly refresh cookie), and **replays** the original request with the new token. 4. The caller receives the replayed response and never knows a refresh happened. Each step has a trap. ## Catch only what you mean to handle ```ts return next(withToken(req, auth.accessToken())).pipe( catchError((err: unknown) => { if (!(err instanceof HttpErrorResponse) || err.status !== HttpStatusCode.Unauthorized) { return throwError(() => err); } return auth.refreshToken().pipe(switchMap((token) => next(withToken(req, token)))); }), ); ``` - Narrow with `instanceof HttpErrorResponse` and check `status === 401`; network failures (status 0), 403 and 5xx are not token problems. - Replay with **`next(...)`**, not `http.get(...)`. Calling `next()` again runs the interceptors after this one and the backend with the new request; the replay does not re-enter this interceptor, so a second 401 simply propagates instead of looping. - `inject(AuthService)` happens synchronously at the top of the interceptor. The `catchError` callback runs later, outside the injection context, where `inject()` would fail with NG0203. ## One refresh for many failures A dashboard that fires five requests at once gets five 401s at nearly the same moment. A naive interceptor starts five refreshes; with rotating refresh tokens, the second one may present a token the first already used, the server rejects it, and the user is logged out. The fix is a **single in-flight refresh** held by the auth service: - The first 401 creates the refresh Observable and stores it. - Later 401s find it stored and subscribe to the same one. - `shareReplay(1)` makes all subscribers share one HTTP call and receive the same new token. - When the refresh finishes (success or error), the stored reference is cleared so the next expiry starts a fresh refresh. ## The refresh call must bypass the interceptor The refresh request goes through the same `HttpClient`, so it passes through the auth interceptor too. If it carried the expired token and received its own 401, the interceptor would ask for a refresh — and get the *same* in-flight Observable, which is waiting for this very request. Nothing would ever complete. Mark the refresh call with a context token such as `SKIP_AUTH` and have the interceptor forward such requests untouched. ## When the refresh fails - A 401 or 400 from the refresh endpoint means the session is over: clear tokens, route to the login page, and rethrow so callers stop waiting. - Do this once, in the auth service's refresh pipeline, rather than in every waiting request's error handler. - A network failure during refresh is not a logout reason; rethrow it as an ordinary error. ## Design choices a senior answer mentions | Choice | Option A | Option B | |---|---|---| | When to refresh | Reactively, on 401 | Proactively, before the known expiry time | | Token storage | In memory, refresh cookie httpOnly | Web storage, readable by any script on the page | | Waiting requests during refresh | Queue on the shared Observable | Fail fast and let callers retry | | Non-idempotent requests | Replay: the 401 means the server rejected the request before acting on it | Do not replay if your API can partially apply work before authorising | A proactive refresh reduces the number of 401s but does not remove the need for the reactive path, because clocks drift and tokens can be revoked server-side. ## Testing it The behaviour worth pinning in tests: five concurrent 401s produce exactly one refresh request, every original request is replayed once with the new token, a rejected refresh logs out once, and the refresh request never carries the bearer header.
- In that interceptor, why can the replayed request not loop forever if the new token is also rejected?The replay calls `next()`, which sends the cloned request to the interceptors after this one and then the backend; it does not pass through this interceptor's `catchError` again. A second 401 therefore travels straight back to the caller as an error. Replaying with `http.request()` instead would re-enter the whole chain and could loop, which is why `next()` is the right tool.
- What goes wrong if the refresh request itself is not excluded from the auth interceptor?It is sent with the expired bearer token, and if the refresh endpoint answers 401, the interceptor asks the auth service for a refresh. The service returns the in-flight refresh Observable — the one waiting on this very request — so nothing ever completes and every queued request hangs. Excluding it, for example with a `SKIP_AUTH` context token, breaks that cycle.
- Should the refresh-and-replay logic also handle 403 responses?Usually not. A 401 means the request lacked valid authentication, which a new token can fix; a 403 means the server knows who the user is and refuses the action, which a new token will not change. Refreshing on 403 adds load and hides authorisation bugs, so let it reach the caller or an error interceptor.
saying these in an interview costs you the question
- Each request that gets a 401 should start its own token refresh.
- The refresh request can safely go through the auth interceptor like any other call.
- Replaying with http.get() inside the interceptor is equivalent to calling next() again.
- It is fine to call inject() inside the catchError callback of an interceptor.
- Any HttpErrorResponse, including status 0 and 403, should trigger a token refresh.