Auth Interceptor End-to-End — Injecting Tokens and Handling 401s

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]))
Note: The 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.
Tip: The 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.
Warning: The module-level 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.

❌ 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.

🧠 Test Yourself

Five concurrent API requests all receive 401 (token expired). The BehaviorSubject queuing is correctly implemented. How many POST /api/auth/refresh calls reach the server?