Angular Auth System — AuthStore, Auth Interceptor, and Guards

The refresh token flow is the mechanism that keeps users logged in for days or weeks without requiring them to re-enter their password, while keeping the authentication window short. The Angular side of this flow requires three interconnected pieces: the AuthStore that holds the current access token as a signal, the auth interceptor that attaches the token to outgoing requests and handles 401 responses by transparently refreshing, and the auth guard that protects routes. Getting all three right — and handling edge cases like multiple simultaneous 401s and token rotation — is the complete auth system.

Auth Flow Architecture

Component Responsibility Location
AuthStore Holds access token signal, user signal, computed auth state core/stores/auth.store.ts
AuthService HTTP calls to auth endpoints (login, register, refresh, logout) core/services/auth.service.ts
Auth Interceptor Attach Bearer token to requests, handle 401 with refresh + retry core/interceptors/auth.interceptor.ts
Auth Guard Protect routes — redirect to login if not authenticated core/guards/auth.guard.ts
Role Guard Protect routes by user role — redirect to forbidden if wrong role core/guards/role.guard.ts
Note: The Angular interceptor must handle the case where multiple requests arrive with expired tokens simultaneously. Without coordination, each 401 would independently trigger a refresh call — sending three or four refresh requests in parallel, all succeeding with different new refresh tokens, but only the last one’s cookie survives. The solution is a shared isRefreshing flag and a BehaviorSubject that queues waiting requests until the single refresh completes (covered in Chapter 12’s interceptor lesson).
Tip: Store the access token in a writable signal in AuthStore, not in sessionStorage or any browser storage. In-memory storage means the token is automatically cleared on page reload or tab close — users need to log in again (or the app silently refreshes using the HttpOnly cookie). This provides better security than sessionStorage (not accessible to scripts) and correct UX (the refresh flow handles reloads transparently).
Warning: The Angular app needs to attempt a token refresh on initial load — not just on 401 responses. When the user reloads the page, the access token in memory is gone but the refresh token HttpOnly cookie may still be valid. Add an APP_INITIALIZER that calls the refresh endpoint on startup. If it succeeds, the user is transparently logged in. If it fails (expired or no cookie), the user sees the login page. Without this, users must re-login on every page reload.

Complete Angular Auth System

// ── AuthStore — signal-based auth state ───────────────────────────────────
// core/stores/auth.store.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { Router } from '@angular/router';

export interface AuthUser {
    id:    string;
    name:  string;
    email: string;
    role:  'user' | 'admin' | 'moderator';
}

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

    // Private writable state
    private _accessToken = signal<string | null>(null);
    private _user        = signal<AuthUser | null>(null);
    private _loading     = signal(false);

    // Public read-only state
    readonly accessToken    = this._accessToken.asReadonly();
    readonly user           = this._user.asReadonly();
    readonly isLoading      = this._loading.asReadonly();

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

    hasRole(role: string | string[]): boolean {
        const userRole = this._user()?.role;
        if (!userRole) return false;
        const roles = Array.isArray(role) ? role : [role];
        return roles.includes(userRole);
    }

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

    setAccessToken(token: string): void {
        this._accessToken.set(token);
    }

    logout(): void {
        this._accessToken.set(null);
        this._user.set(null);
        this.router.navigate(['/auth/login']);
    }

    setLoading(v: boolean): void { this._loading.set(v); }
}

// ── AuthService — HTTP calls ──────────────────────────────────────────────
// core/services/auth.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient }         from '@angular/common/http';
import { tap }                from 'rxjs/operators';
import { API_BASE_URL }       from '../tokens';
import { AuthStore, AuthUser }from '../stores/auth.store';

interface AuthResponse {
    success: boolean;
    data:    { accessToken: string; user: AuthUser };
}

@Injectable({ providedIn: 'root' })
export class AuthService {
    private http     = inject(HttpClient);
    private authStore= inject(AuthStore);
    private baseUrl  = inject(API_BASE_URL);

    login(email: string, password: string) {
        return this.http.post<AuthResponse>(
            `${this.baseUrl}/auth/login`,
            { email, password },
            { withCredentials: true }   // send/receive cookies
        ).pipe(
            tap(res => this.authStore.setTokenAndUser(
                res.data.accessToken, res.data.user
            ))
        );
    }

    register(name: string, email: string, password: string) {
        return this.http.post<AuthResponse>(
            `${this.baseUrl}/auth/register`,
            { name, email, password },
            { withCredentials: true }
        ).pipe(
            tap(res => this.authStore.setTokenAndUser(
                res.data.accessToken, res.data.user
            ))
        );
    }

    refreshToken() {
        return this.http.post<{ success: boolean; data: { accessToken: string } }>(
            `${this.baseUrl}/auth/refresh`,
            {},
            { withCredentials: true }   // sends the HttpOnly cookie
        ).pipe(
            tap(res => this.authStore.setAccessToken(res.data.accessToken))
        );
    }

    logout() {
        return this.http.post(
            `${this.baseUrl}/auth/logout`,
            {},
            { withCredentials: true }
        ).pipe(
            tap(() => this.authStore.logout())
        );
    }
}

// ── Auth guard ────────────────────────────────────────────────────────────
// core/guards/auth.guard.ts
import { inject }         from '@angular/core';
import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AuthStore }      from '../stores/auth.store';

export const authGuard: CanActivateFn = (
    _route: ActivatedRouteSnapshot,
    state:  RouterStateSnapshot,
) => {
    const authStore = inject(AuthStore);
    const router    = inject(Router);

    if (authStore.isAuthenticated()) return true;

    return router.createUrlTree(['/auth/login'], {
        queryParams: { returnUrl: state.url },
    });
};

// ── Role guard ────────────────────────────────────────────────────────────
export function roleGuard(requiredRole: string | string[]): CanActivateFn {
    return () => {
        const authStore = inject(AuthStore);
        const router    = inject(Router);

        if (!authStore.isAuthenticated()) {
            return router.createUrlTree(['/auth/login']);
        }
        if (authStore.hasRole(requiredRole)) return true;

        return router.createUrlTree(['/forbidden']);
    };
}

// ── APP_INITIALIZER — restore session on page reload ─────────────────────
// app.config.ts
import { APP_INITIALIZER }  from '@angular/core';
import { AuthService }      from './core/services/auth.service';
import { catchError, of }   from 'rxjs';

function initAuth(authService: AuthService) {
    return () => authService.refreshToken().pipe(
        catchError(() => of(null))   // fail silently — user just needs to log in
    );
}

export const appConfig = {
    providers: [
        {
            provide:    APP_INITIALIZER,
            useFactory: initAuth,
            deps:       [AuthService],
            multi:      true,
        },
        provideRouter(routes),
        provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
    ],
};

// ── Login component ───────────────────────────────────────────────────────
@Component({
    selector:   'app-login',
    standalone: true,
    imports:    [ReactiveFormsModule, CommonModule, RouterModule],
    template: `
        <form [formGroup]="form" (ngSubmit)="onSubmit()">
            <input formControlName="email" type="email" placeholder="Email">
            <input formControlName="password" type="password" placeholder="Password">
            <p *ngIf="error()" class="error">{{ error() }}</p>
            <button type="submit" [disabled]="loading() || form.invalid">
                {{ loading() ? 'Signing in...' : 'Sign In' }}
            </button>
        </form>
        <a routerLink="/auth/register">Create account</a>
    `,
})
export class LoginComponent {
    private fb          = inject(NonNullableFormBuilder);
    private authService = inject(AuthService);
    private route       = inject(ActivatedRoute);
    private router      = inject(Router);

    loading = signal(false);
    error   = signal<string | null>(null);

    form = this.fb.group({
        email:    ['', [Validators.required, Validators.email]],
        password: ['', [Validators.required, Validators.minLength(8)]],
    });

    onSubmit(): void {
        this.form.markAllAsTouched();
        if (this.form.invalid) return;

        this.loading.set(true);
        this.error.set(null);

        const { email, password } = this.form.getRawValue();
        this.authService.login(email, password).subscribe({
            next: () => {
                const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/tasks';
                this.router.navigateByUrl(returnUrl);
            },
            error: err => {
                this.error.set(err.error?.message ?? 'Login failed. Please try again.');
                this.loading.set(false);
            },
        });
    }
}

How It Works

The AuthStore holds the access token in a private signal. It is never written to any browser storage. When the page reloads, the signal is null and the APP_INITIALIZER calls the refresh endpoint. The browser automatically sends the HttpOnly refresh token cookie. If valid, the server returns a new access token which is stored in the signal. The user is transparently re-authenticated without seeing a login page.

Step 2 — withCredentials Sends Cookies Cross-Origin

The Angular frontend (port 4200) and Express backend (port 3000) are different origins in development. Cookies are only sent cross-origin if both the request includes withCredentials: true AND the server responds with Access-Control-Allow-Credentials: true and a specific Access-Control-Allow-Origin (not *). The CORS configuration on the Express server must explicitly allow the Angular origin and credentials.

Step 3 — APP_INITIALIZER Restores Session Before Router Activates

APP_INITIALIZER functions must complete before Angular renders any routes. By returning an Observable from the initialiser, Angular waits for the refresh call to complete or fail before activating any route guard. This means the auth guard sees the correct isAuthenticated() state — not the initial empty state — when it first runs on app load.

Step 4 — The Auth Guard Returns a UrlTree for Redirect

Returning router.createUrlTree(['/auth/login'], { queryParams: { returnUrl: state.url } }) from the guard is a single atomic navigation — it prevents the intended route AND navigates to login in one step, preserving the return URL. After successful login, the component reads the returnUrl query parameter and navigates to the originally requested page.

Step 5 — Role Guard Factory Enables Declarative Role Checking

canActivate: [authGuard, roleGuard('admin')] runs both guards in order. The auth guard checks authentication first. Only if authenticated does the role guard run — checking whether the authenticated user has the required role. This separation of concerns keeps each guard simple and allows combining them freely in route configurations.

Common Mistakes

Mistake 1 — Not sending withCredentials on auth HTTP calls

❌ Wrong — refresh token cookie never sent or received:

this.http.post('/api/v1/auth/refresh', {})  // no withCredentials — cookie not sent!
// Server receives no cookie, returns 401

✅ Correct — always include withCredentials for auth endpoints:

this.http.post('/api/v1/auth/refresh', {}, { withCredentials: true })

Mistake 2 — Not handling the 401 during APP_INITIALIZER

❌ Wrong — unhandled error crashes the app initialisation:

function initAuth(authService: AuthService) {
    return () => authService.refreshToken();  // throws if no refresh cookie → app crashes!
}

✅ Correct — catch and return null (user simply sees login page):

return () => authService.refreshToken().pipe(catchError(() => of(null)));

Mistake 3 — Using router.navigate() instead of UrlTree in guards

❌ Wrong — two separate navigations may race:

if (!authenticated) {
    router.navigate(['/auth/login']);  // starts a new navigation
    return false;                      // also cancels current — two competing navigations
}

✅ Correct — return a UrlTree for a single atomic redirect:

if (!authenticated) {
    return router.createUrlTree(['/auth/login']);  // single redirect
}

Quick Reference

Task Code
Store token in memory private _token = signal<string | null>(null)
Is authenticated isAuthenticated = computed(() => !!this._token())
Login HTTP call http.post('/auth/login', creds, { withCredentials: true })
Refresh HTTP call http.post('/auth/refresh', {}, { withCredentials: true })
Restore on reload APP_INITIALIZER factory calling authService.refreshToken()
Auth guard Return router.createUrlTree(['/login']) if not authenticated
Role guard Return router.createUrlTree(['/forbidden']) if wrong role
Clear auth state this._token.set(null); this._user.set(null)

🧠 Test Yourself

A user refreshes the page. The access token signal in AuthStore is now null. The auth guard runs before the refresh completes. What prevents the user from being incorrectly redirected to login?