Auth Interceptor — Token Injection, Refresh and Retry

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));
}
Note: The concurrent request queuing pattern using 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.
Tip: The 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.
Warning: The module-level 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.

🧠 Test Yourself

Five concurrent API requests all receive 401 responses. The interceptor’s BehaviorSubject queuing is correctly implemented. How many token refresh calls are made to the server?