The auth interceptor is the glue between Angular’s HTTP layer and the authentication service. It automatically adds the JWT Bearer token to outgoing requests, handles 401 responses by attempting token refresh, and retries the failed request with the new token — all transparently from the perspective of services and components. The most complex part is handling concurrent 401 responses: if 3 requests fail simultaneously, only one refresh call should be made, and all 3 requests should retry after refresh completes.
Production Auth Interceptor
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn,
HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { BehaviorSubject, switchMap, filter, take, throwError, catchError } from 'rxjs';
// ── Shared state for concurrent request queuing ────────────────────────────
// These live outside the interceptor function to persist across calls
let isRefreshing = false;
let refreshToken$ = new BehaviorSubject<string | null>(null);
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const router = inject(Router);
// ── Skip auth for public endpoints ────────────────────────────────────
if (isPublicEndpoint(req.url)) return next(req);
// ── Inject access token ───────────────────────────────────────────────
const token = authService.accessToken();
const authReq = token ? addToken(req, token) : req;
return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status !== 401) return throwError(() => error);
if (isRefreshing) {
// ── Another request is already refreshing — queue this one ───────
return refreshToken$.pipe(
filter(token => token !== null), // wait for the new token
take(1), // take only the first emission
switchMap(token => next(addToken(req, token!)))
);
}
// ── Start token refresh ───────────────────────────────────────────
isRefreshing = true;
refreshToken$.next(null); // signal "refresh in progress"
return authService.refreshToken$().pipe(
switchMap(newToken => {
isRefreshing = false;
refreshToken$.next(newToken); // broadcast new token to queued requests
return next(addToken(req, newToken));
}),
catchError(refreshError => {
isRefreshing = false;
refreshToken$.next(null);
// Refresh failed — session expired, force logout
authService.clearSession();
router.navigate(['/auth/login'], {
queryParams: { sessionExpired: true }
});
return throwError(() => refreshError);
}),
);
}),
);
};
function addToken(req: HttpRequest<any>, token: string): HttpRequest<any> {
return req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
withCredentials: true, // send httpOnly cookies
});
}
function isPublicEndpoint(url: string): boolean {
const publicPaths = ['/api/auth/login', '/api/auth/refresh', '/api/posts'];
return publicPaths.some(path => url.includes(path));
}
BehaviorSubject solves the “refresh stampede” problem. When multiple 401 responses arrive simultaneously (e.g., 5 background requests all have expired tokens), only the first one triggers authService.refreshToken(). The other 4 see isRefreshing === true and subscribe to refreshToken$ which emits null (indicating refresh in progress). When refresh completes, refreshToken$.next(newToken) broadcasts the new token to all 4 waiting requests, which then retry with it. Without this pattern, 5 parallel refresh calls are made — most will fail because refresh tokens are single-use.isPublicEndpoint() check is important for preventing circular refresh loops. If the refresh endpoint itself returns a 401 (the refresh token is expired or revoked), the interceptor would try to refresh again, get another 401, and loop indefinitely. Always exclude the refresh, login, and logout endpoints from token injection and 401 handling. Also exclude public read endpoints (/api/posts) if they work without authentication — adding an unnecessary Bearer header slightly increases request size.isRefreshing and refreshToken$ variables persist across requests because the interceptor function is a singleton. This is intentional for the queuing pattern. However, if the application is used in a multi-tab scenario (multiple browser tabs sharing the same session), each tab has its own JavaScript context with its own isRefreshing state — two tabs may simultaneously refresh the token. The server’s refresh token rotation (invalidating old tokens) detects this: the second tab’s refresh fails because the first tab already rotated the token.Common Mistakes
Mistake 1 — No concurrent refresh queuing (multiple simultaneous refreshes)
❌ Wrong — each 401 triggers its own refresh call; server invalidates first refresh token; second refresh fails; user logged out.
✅ Correct — use the BehaviorSubject queuing pattern to ensure exactly one refresh per 401 burst.
Mistake 2 — Not excluding public endpoints from token injection (unnecessary auth on public API)
❌ Wrong — Bearer token added to /api/posts (public endpoint); if token is expired, user cannot view public content.
✅ Correct — check URL against a list of public endpoints and skip token injection for them.