Angular Core — Routing, AuthStore, Interceptors, and Lazy-Loaded Features

The Angular application foundation is where routing, state management architecture, HTTP interceptors, authentication guards, and the core services are established. Decisions made here — how state flows through the application, how API errors are handled globally, how authentication state is managed — affect every feature built afterwards. This lesson builds the complete Angular core layer: routing with lazy-loaded feature modules, the AuthStore and its effect on navigation, HTTP interceptors for token attachment and error handling, and the core services that every feature module will depend on.

Angular Core Architecture

Layer Responsibility Location
AppComponent Shell — router outlet, navigation bar app/app.component.ts
AppRoutes Top-level routes — lazy-load feature modules app/app.routes.ts
AuthStore Authentication state — tokens, user, login/logout actions core/stores/auth.store.ts
AuthInterceptor Attach access token to all API requests core/interceptors/auth.interceptor.ts
ErrorInterceptor Handle 401 (refresh), 429 (back-off), 5xx (toast) core/interceptors/error.interceptor.ts
AuthGuard Block unauthenticated navigation to protected routes core/guards/auth.guard.ts
WorkspaceGuard Ensure a valid workspace is selected core/guards/workspace.guard.ts
ApiService Typed HTTP wrapper — maps response envelope to data core/services/api.service.ts
ToastService Global toast notifications core/services/toast.service.ts
Note: Store the access token in memory (a signal or service property), not in localStorage. Access tokens in localStorage are vulnerable to XSS — any injected script can read them. Store the refresh token in an httpOnly cookie (set by the server) — it cannot be read by JavaScript at all. On page refresh, the Angular app starts with no access token, makes a silent refresh request (the httpOnly cookie is sent automatically), receives a new access token, and stores it in memory. This “auth on load” pattern is the secure standard for SPAs.
Tip: Use catchError in the error interceptor to handle 401 responses by attempting a token refresh before failing. The interceptor stores the in-flight 401 request, calls the refresh endpoint, updates the access token, then retries the original request with the new token — completely transparent to the component that made the original API call. Use BehaviorSubject<boolean> to prevent multiple simultaneous refresh attempts (only one refresh runs at a time; subsequent 401s wait for the first to complete).
Warning: Lazy loading Angular feature modules reduces the initial bundle size but requires careful circular dependency management. The AuthStore injected into a feature component must be in the root injector (providedIn: 'root') — not in the feature module’s providers — to avoid creating multiple instances. Services that must be singletons (stores, the socket service, the toast service) should always use providedIn: 'root', regardless of which feature module first uses them.

Complete Angular Core Setup

// ── app/app.routes.ts — lazy-loaded feature routes ─────────────────────────
import { Routes }     from '@angular/router';
import { authGuard }  from './core/guards/auth.guard';
import { workspaceGuard } from './core/guards/workspace.guard';

export const APP_ROUTES: Routes = [
    {
        path: 'auth',
        loadChildren: () =>
            import('./features/auth/auth.routes').then(m => m.AUTH_ROUTES),
    },
    {
        path: 'workspaces',
        canActivate: [authGuard],
        loadChildren: () =>
            import('./features/workspaces/workspace.routes').then(m => m.WORKSPACE_ROUTES),
    },
    {
        path: 'w/:workspaceSlug',
        canActivate: [authGuard, workspaceGuard],
        loadChildren: () =>
            import('./features/tasks/task.routes').then(m => m.TASK_ROUTES),
    },
    {
        path:       '',
        redirectTo: 'workspaces',
        pathMatch:  'full',
    },
    {
        path:         '**',
        loadComponent: () =>
            import('./shared/components/not-found/not-found.component')
            .then(m => m.NotFoundComponent),
    },
];

// ── core/stores/auth.store.ts ─────────────────────────────────────────────
import { Injectable, signal, computed, inject } from '@angular/core';
import { Router }     from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { tap }        from 'rxjs/operators';
import { User, AuthTokens } from '@taskmanager/shared';
import { environment }      from '../../../environments/environment';

@Injectable({ providedIn: 'root' })
export class AuthStore {
    private http   = inject(HttpClient);
    private router = inject(Router);

    // State — access token in memory (NOT localStorage)
    private _accessToken = signal<string | null>(null);
    private _user        = signal<User | null>(null);
    private _loading     = signal(false);
    private _initialized = signal(false);

    // Public read-only
    readonly accessToken  = this._accessToken.asReadonly();
    readonly user         = this._user.asReadonly();
    readonly loading      = this._loading.asReadonly();
    readonly initialized  = this._initialized.asReadonly();

    // Derived
    readonly isAuthenticated = computed(() => !!this._accessToken());
    readonly isAdmin         = computed(() => this._user()?.role === 'admin');

    // Attempt silent refresh on app init
    initialize(): void {
        this._loading.set(true);
        this.http.post<{ data: { accessToken: string; user: User } }>(
            `${environment.apiUrl}/auth/refresh`, {},
            { withCredentials: true }   // send httpOnly refresh cookie
        ).subscribe({
            next: res => {
                this._accessToken.set(res.data.accessToken);
                this._user.set(res.data.user);
            },
            error: () => {},   // no valid cookie — stay unauthenticated
            complete: () => {
                this._loading.set(false);
                this._initialized.set(true);
            },
        });
    }

    login(email: string, password: string) {
        return this.http.post<{ data: AuthTokens & { user: User } }>(
            `${environment.apiUrl}/auth/login`, { email, password },
            { withCredentials: true }
        ).pipe(
            tap(res => {
                this._accessToken.set(res.data.accessToken);
                this._user.set(res.data.user);
            })
        );
    }

    logout(): void {
        this.http.post(`${environment.apiUrl}/auth/logout`, {},
            { withCredentials: true }).subscribe();
        this._accessToken.set(null);
        this._user.set(null);
        this.router.navigate(['/auth/login']);
    }

    setTokenAndUser(token: string, user: User): void {
        this._accessToken.set(token);
        this._user.set(user);
    }
}

// ── core/interceptors/auth.interceptor.ts ────────────────────────────────
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from '../stores/auth.store';

export const authInterceptor: HttpInterceptorFn = (
    req: HttpRequest<unknown>, next: HttpHandlerFn
) => {
    const authStore = inject(AuthStore);
    const token     = authStore.accessToken();

    // Skip auth header for the refresh endpoint itself
    if (!token || req.url.includes('/auth/refresh')) {
        return next(req);
    }

    const cloned = req.clone({
        setHeaders: { Authorization: `Bearer ${token}` },
    });
    return next(cloned);
};

// ── core/interceptors/error.interceptor.ts ────────────────────────────────
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject }   from '@angular/core';
import { Router }   from '@angular/router';
import { catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs';
import { AuthStore }  from '../stores/auth.store';
import { ToastService } from '../services/toast.service';

let isRefreshing     = false;
const refreshSubject = new BehaviorSubject<string | null>(null);

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
    const authStore = inject(AuthStore);
    const toast     = inject(ToastService);
    const router    = inject(Router);
    const http      = inject(HttpClient);

    return next(req).pipe(
        catchError((err: HttpErrorResponse) => {
            if (err.status === 401 && !req.url.includes('/auth/')) {
                // Token expired — attempt refresh
                if (!isRefreshing) {
                    isRefreshing = true;
                    refreshSubject.next(null);

                    return http.post<{ data: { accessToken: string } }>(
                        `${environment.apiUrl}/auth/refresh`, {},
                        { withCredentials: true }
                    ).pipe(
                        switchMap(res => {
                            isRefreshing = false;
                            const token  = res.data.accessToken;
                            refreshSubject.next(token);
                            authStore.setTokenAndUser(token, authStore.user()!);
                            // Retry original request with new token
                            return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
                        }),
                        catchError(refreshErr => {
                            isRefreshing = false;
                            authStore.logout();
                            return throwError(() => refreshErr);
                        }),
                    );
                }

                // Another request is already refreshing — wait for new token
                return refreshSubject.pipe(
                    filter(t => t !== null),
                    take(1),
                    switchMap(token =>
                        next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))
                    ),
                );
            }

            if (err.status >= 500) {
                toast.error('Something went wrong. Please try again.');
            }
            if (err.status === 429) {
                toast.warning('Too many requests. Please slow down.');
            }

            return throwError(() => err);
        })
    );
};

// ── app.config.ts — provide interceptors ─────────────────────────────────
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor }  from './core/interceptors/auth.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';

export const appConfig = {
    providers: [
        provideRouter(APP_ROUTES, withPreloading(PreloadAllModules)),
        provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
    ],
};

How It Works

Step 1 — Access Token in Memory Prevents XSS Token Theft

A signal holding the access token in Angular’s memory (not in localStorage) cannot be accessed by injected scripts — XSS attacks can only read storage APIs and DOM, not JavaScript module scope. On page refresh, the token is lost and the initialize() method performs a silent refresh using the httpOnly cookie. The refresh endpoint returns a new access token, which is stored back in memory. This is the secure pattern for SPAs recommended by OAuth 2.0 BCP (Best Current Practice).

The refresh token is stored in an httpOnly cookie set by the server with Secure, SameSite=Strict, and a long expiry. The browser sends this cookie automatically with same-origin requests. For cross-origin requests (Angular on port 4200, API on port 3000), withCredentials: true in the HttpClient request tells the browser to include cookies. The server’s CORS configuration must also include credentials: true and list the specific origin (not *).

Step 3 — BehaviorSubject Prevents Multiple Simultaneous Refresh Calls

When the access token expires, multiple in-flight API requests may receive 401 simultaneously. Without coordination, each would trigger its own token refresh call. The isRefreshing flag and refreshSubject coordinate this: the first 401 triggers the refresh; subsequent 401s subscribe to refreshSubject and wait for the new token. When the refresh completes, refreshSubject.next(newToken) unblocks all waiting requests, which then retry with the new token.

Step 4 — Lazy Loading Reduces Initial Bundle Size

Each loadChildren: () => import(...). tells the Angular router to create a separate chunk for that feature module. The initial bundle only contains the core app code — the authentication module is loaded when the user navigates to /auth, the tasks module when they navigate to /w/:slug. For a large application, this reduces the initial load from 3MB to under 500KB — the difference between a 1-second load and a 6-second load on a mobile connection.

Step 5 — authGuard Protects Routes Using Signals

The canActivate: [authGuard] on protected routes calls the guard function before rendering any component. The guard checks authStore.isAuthenticated() — if false, it navigates to /auth/login and returns false (preventing the route from rendering). If the app is still initialising (!authStore.initialized()), the guard waits for initialized to become true. This prevents the flash of the login page that would occur if the guard ran before the silent refresh completes.

Quick Reference

Task Code
Lazy load feature loadChildren: () => import('./feature/routes').then(m => m.ROUTES)
Protect route canActivate: [authGuard]
Attach token req.clone({ setHeaders: { Authorization: 'Bearer ' + token } })
Send cookies cross-origin http.post(url, body, { withCredentials: true })
Provide interceptors provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
Access token in memory private _token = signal<string | null>(null)
Is authenticated isAuthenticated = computed(() => !!this._token())
Initialize on load APP_INITIALIZER: () => authStore.initialize()

🧠 Test Yourself

An access token expires and 5 API calls are made simultaneously, all receiving 401. How does the error interceptor’s isRefreshing flag prevent 5 simultaneous refresh requests?