The auth interceptor is the glue between the Angular HTTP layer and the AuthService — it automatically adds the Bearer token to every API request and handles the 401 response by refreshing the token and retrying. The most critical implementation detail is the concurrent refresh queuing: if multiple requests fail with 401 simultaneously (due to an expired token), only one refresh call should be made, and all pending requests should retry with the new token.
Auth Interceptor Implementation
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn,
HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { BehaviorSubject, throwError } from 'rxjs';
import { switchMap, filter, take, catchError } from 'rxjs/operators';
// ── Module-level state for concurrent refresh queuing ──────────────────────
// Lives outside the function — persists across interceptor calls
let isRefreshing = false;
let refreshToken$ = new BehaviorSubject<string | null>(null);
// ── Auth interceptor function ──────────────────────────────────────────────
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const config = inject(APP_CONFIG);
// Skip auth endpoints (login, refresh, logout) — they don't need Bearer tokens
if (isAuthEndpoint(req.url, config.apiUrl)) {
return next(req.clone({ withCredentials: true }));
}
// Add Bearer token to the request
const token = auth.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(t => t !== null), // wait for the new token
take(1),
switchMap(newToken => next(addToken(req, newToken!))),
);
}
// Start the refresh
isRefreshing = true;
refreshToken$.next(null); // signal "refresh in progress"
return auth.refreshToken().pipe(
switchMap(newToken => {
isRefreshing = false;
refreshToken$.next(newToken); // broadcast to queued requests
return next(addToken(req, newToken));
}),
catchError(refreshError => {
isRefreshing = false;
refreshToken$.next(null);
// Refresh failed — clear session and redirect to login
auth.clearSession(); // make this method public in AuthService
return throwError(() => refreshError);
}),
);
}),
);
};
// ── Helpers ────────────────────────────────────────────────────────────────
function addToken(req: HttpRequest<any>, token: string): HttpRequest<any> {
return req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
withCredentials: true, // send httpOnly cookies (refresh token)
});
}
function isAuthEndpoint(url: string, apiUrl: string): boolean {
const authPaths = ['/api/auth/login', '/api/auth/refresh', '/api/auth/logout'];
return authPaths.some(path => url.includes(path));
}
// ── Register in app.config.ts ──────────────────────────────────────────────
// provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
withCredentials: true on all authenticated requests is required to send the httpOnly refresh token cookie to the API. Without it, the browser omits the cookie from cross-origin requests. Since all BlogApp API requests are potentially cross-origin (in production, Angular is on blogapp.com and the API is on api.blogapp.com), every request that might trigger a token refresh needs withCredentials: true. This is handled in the addToken() helper function which sets it for all authenticated requests.isAuthEndpoint() check is critical for two reasons: preventing circular requests (the refresh endpoint must not trigger its own refresh on failure), and avoiding token injection on public endpoints (the login endpoint should not receive a Bearer token). The simplest implementation checks URL strings, but a more robust approach is to use a custom HTTP header that marks requests as auth-exempt: req.headers.has('X-Skip-Auth'). This avoids string matching brittleness for dynamically constructed URLs.isRefreshing and refreshToken$ state means all interceptor calls share this state. In a Server-Side Rendering (Angular Universal/SSR) context, module-level state is shared across all requests on the server — one user’s token refresh could affect another user’s requests. For SSR, use a request-scoped service to hold this state instead of module-level variables. For client-side-only Angular apps, module-level state works correctly.Common Mistakes
Mistake 1 — No concurrent refresh queuing (multiple refresh calls race)
❌ Wrong — each 401 triggers its own refresh; server invalidates first refresh token; second refresh fails; user logged out.
✅ Correct — BehaviorSubject queuing pattern: one refresh call, all pending requests wait and retry with new token.
Mistake 2 — Forgetting withCredentials: true on authenticated requests (refresh cookie not sent)
❌ Wrong — request without withCredentials; browser omits httpOnly cookie; refresh endpoint cannot find the token.
✅ Correct — always include withCredentials: true in addToken() for all API requests.