Skip to main content

Angular Integration (with SSR)

This guide covers a production-ready Angular 17+ standalone application that integrates with node-auth. It includes:

  • Cookie-based auth โ€” node-auth manages access token, refresh token, and CSRF token automatically via HttpOnly cookies; no manual token storage needed
  • HTTP interceptor with CSRF header support and concurrent-refresh queue (handles multiple simultaneous 401s)
  • authGuard, guestGuard, roleGuard, and canMatchGuard
  • APP_INITIALIZER for silent session restore on page reload
  • Full SSR wiring that forwards browser cookies to the API server
Cookie-based vs Bearer Token

By default, node-auth sets accessToken, refreshToken, and csrf-token as HttpOnly cookies on every login/refresh response. The Angular app does not need to read, store, or attach these tokens manually โ€” the browser handles them automatically when withCredentials: true is set.

Bearer-token mode (where tokens are returned in the response body and managed manually) is intended for native/mobile clients. See the Bearer Token guide for that approach.

Project structureโ€‹

src/app/
โ”œโ”€โ”€ auth/
โ”‚ โ”œโ”€โ”€ auth.service.ts # user state + login/logout/refresh
โ”‚ โ”œโ”€โ”€ auth.interceptor.ts # CSRF header + handle 401 + refresh queue
โ”‚ โ”œโ”€โ”€ auth.guard.ts # authGuard, guestGuard, roleGuard, canMatchGuard
โ”‚ โ”œโ”€โ”€ auth.initializer.ts # APP_INITIALIZER โ€“ silent restore on reload
โ”‚ โ””โ”€โ”€ ssr-cookie.interceptor.ts # SSR: forward browser cookies to API
โ”œโ”€โ”€ app.config.ts # browser providers
โ”œโ”€โ”€ app.config.server.ts # server-side providers (SSR)
โ”œโ”€โ”€ app.routes.ts # full route config
โ””โ”€โ”€ pages/
โ”œโ”€โ”€ login/login.component.ts
โ””โ”€โ”€ dashboard/dashboard.component.ts

Installationโ€‹

# Angular 17+ built-in SSR scaffold
ng new my-app --ssr
cd my-app

# or add SSR to an existing project
ng add @angular/ssr

Step 1 โ€” Auth Serviceโ€‹

The service owns all user state. Because node-auth delivers tokens via HttpOnly cookies, the Angular app never reads or stores tokens directly โ€” it simply calls the auth endpoints with withCredentials: true and fetches user data from GET /auth/me.

// src/app/auth/auth.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { tap, catchError, map, switchMap } from 'rxjs/operators';

export interface AuthUser {
sub: string;
email: string;
role?: string;
tenantId?: string;
/** Additional custom claims added via `config.buildTokenPayload`. */
[key: string]: unknown;
}

@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);

private _user$ = new BehaviorSubject<AuthUser | null>(null);

readonly user$ = this._user$.asObservable();

get currentUser(): AuthUser | null { return this._user$.value; }
get isLoggedIn(): boolean { return this._user$.value !== null; }

// โ”€โ”€ Login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// node-auth sets accessToken + refreshToken + csrf-token cookies on success.
// We then call /auth/me to get the current user's profile.

login(email: string, password: string): Observable<AuthUser> {
return this.http
.post<void>('/auth/login', { email, password }, { withCredentials: true })
.pipe(switchMap(() => this.fetchCurrentUser()));
}

// โ”€โ”€ Logout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

logout(): Observable<void> {
return this.http
.post<void>('/auth/logout', {}, { withCredentials: true })
.pipe(
tap(() => this._user$.next(null)),
catchError(() => { this._user$.next(null); return of(void 0); }),
);
}

// โ”€โ”€ Refresh โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// The refreshToken HttpOnly cookie is sent automatically โ€” no manual token
// handling required. After a successful refresh, new cookies are set and
// we re-fetch the user profile.

refresh(): Observable<AuthUser> {
return this.http
.post<void>('/auth/refresh', {}, { withCredentials: true })
.pipe(switchMap(() => this.fetchCurrentUser()));
}

// โ”€โ”€ Silent restore (called by APP_INITIALIZER) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Tries to silently refresh the session using the stored refresh cookie.
// Returns true on success, false if no valid session exists.

tryRestoreSession(): Observable<boolean> {
return this.refresh().pipe(
map(() => true),
catchError(() => of(false)),
);
}

// โ”€โ”€ Fetch current user โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fetchCurrentUser(): Observable<AuthUser> {
return this.http
.get<AuthUser>('/auth/me', { withCredentials: true })
.pipe(tap((user) => this._user$.next(user)));
}
}

Step 2 โ€” HTTP Interceptor (CSRF + refresh queue)โ€‹

node-auth uses the double-submit cookie pattern for CSRF protection when csrf.enabled is true. The csrf-token cookie is readable by JavaScript (not HttpOnly). The interceptor reads it and sends it as an X-CSRF-Token header on POST, PUT, PATCH, and DELETE requests.

A naive interceptor that simply retries on 401 breaks when multiple requests fire concurrently โ€” each would trigger its own refresh call, causing race conditions.
The implementation below uses a refresh queue: the first 401 starts a single refresh; all subsequent 401s wait for that same refresh to complete.

// src/app/auth/auth.interceptor.ts
import {
HttpInterceptorFn,
HttpRequest,
HttpHandlerFn,
HttpErrorResponse,
} from '@angular/common/http';
import { inject } from '@angular/core';
import { BehaviorSubject, throwError, Observable } from 'rxjs';
import { catchError, switchMap, filter, take } from 'rxjs/operators';
import { AuthService } from './auth.service';

// Module-level state so the queue survives across calls
let isRefreshing = false;
const refreshDone$ = new BehaviorSubject<boolean>(false);

export const authInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn,
) => {
const auth = inject(AuthService);

// Add X-CSRF-Token header to state-changing requests (CSRF double-submit pattern)
const modified = addCsrfHeader(req);

return next(modified).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status !== 401 || req.url.includes('/auth/refresh')) {
return throwError(() => err);
}
return handle401(req, next, auth);
}),
);
};

// โ”€โ”€ CSRF header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function addCsrfHeader(req: HttpRequest<unknown>): HttpRequest<unknown> {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return req;
const token = getCsrfCookie();
if (!token) return req;
return req.clone({ setHeaders: { 'X-CSRF-Token': token } });
}

function getCsrfCookie(): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/(?:^|;\s*)csrf-token=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}

// โ”€โ”€ 401 handler with queue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function handle401(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
auth: AuthService,
): Observable<unknown> {
if (!isRefreshing) {
isRefreshing = true;
refreshDone$.next(false);

return auth.refresh().pipe(
switchMap(() => {
isRefreshing = false;
refreshDone$.next(true); // unblock queued requests
return next(addCsrfHeader(req));
}),
catchError((refreshErr) => {
isRefreshing = false;
refreshDone$.next(false);
auth.logout().subscribe();
return throwError(() => refreshErr);
}),
);
}

// Another refresh is already in-flight โ€” wait for it to complete
return refreshDone$.pipe(
filter((done) => done === true),
take(1),
switchMap(() => next(addCsrfHeader(req))),
);
}

Step 3 โ€” Guardsโ€‹

authGuard โ€” require loginโ€‹

// src/app/auth/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, CanMatchFn, Router, UrlTree } from '@angular/router';
import { map, take } from 'rxjs/operators';
import { AuthService } from './auth.service';

/** Redirect unauthenticated users to /login. */
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);

return auth.user$.pipe(
take(1),
map((user) =>
user ? true : router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }),
),
);
};

/** Same check for canMatch (lazy-loaded feature modules). */
export const authCanMatch: CanMatchFn = () => {
const auth = inject(AuthService);
const router = inject(Router);

return auth.user$.pipe(
take(1),
map((user): boolean | UrlTree => user ? true : router.createUrlTree(['/login'])),
);
};

guestGuard โ€” redirect already-logged-in users away from /loginโ€‹

/** Redirect authenticated users away from the login page. */
export const guestGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);

return auth.user$.pipe(
take(1),
map((user) => user ? router.createUrlTree(['/dashboard']) : true),
);
};

roleGuard โ€” role-based access controlโ€‹

/** Require a specific role. Usage: canActivate: [roleGuard('admin')] */
export function roleGuard(requiredRole: string): CanActivateFn {
return () => {
const auth = inject(AuthService);
const router = inject(Router);

return auth.user$.pipe(
take(1),
map((user): boolean | UrlTree => {
if (!user) return router.createUrlTree(['/login']);
if (user.role !== requiredRole) return router.createUrlTree(['/403']);
return true;
}),
);
};
}

Step 4 โ€” Route Configurationโ€‹

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard, authCanMatch, guestGuard, roleGuard } from './auth/auth.guard';

export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },

// Public โ€” redirect to /dashboard if already logged in
{
path: 'login',
canActivate: [guestGuard],
loadComponent: () => import('./pages/login/login.component'),
},
{
path: 'register',
canActivate: [guestGuard],
loadComponent: () => import('./pages/register/register.component'),
},

// Protected โ€” requires authentication
{
path: 'dashboard',
canActivate: [authGuard],
canMatch: [authCanMatch],
loadComponent: () => import('./pages/dashboard/dashboard.component'),
},

// Protected โ€” requires admin role
{
path: 'admin',
canActivate: [roleGuard('admin')],
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},

{ path: '403', loadComponent: () => import('./pages/forbidden/forbidden.component') },
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component') },
];

Step 5 โ€” APP_INITIALIZER (silent session restore)โ€‹

When the user reloads the page, the AuthService user state is lost, but the refresh token HttpOnly cookie is still in the browser. APP_INITIALIZER runs before the app renders, calls POST /auth/refresh (which sets fresh cookies server-side), then fetches the user profile so the user stays logged in.

// src/app/auth/auth.initializer.ts
import { inject } from '@angular/core';
import { APP_INITIALIZER, EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
import { AuthService } from './auth.service';

function initializeAuth(auth: AuthService, platformId: object) {
return () => {
// Skip on server โ€” the SSR context handles its own auth (see Step 7)
if (!isPlatformBrowser(platformId)) return Promise.resolve();
return auth.tryRestoreSession().toPromise();
};
}

/** Drop this into the `providers` array of `appConfig`. */
export function provideAuthInitializer(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: APP_INITIALIZER,
useFactory: (auth: AuthService, platformId: object) => initializeAuth(auth, platformId),
deps: [AuthService, PLATFORM_ID],
multi: true,
},
]);
}

Step 6 โ€” App Configuration (browser)โ€‹

// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
import { authInterceptor } from './auth/auth.interceptor';
import { provideAuthInitializer } from './auth/auth.initializer';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(
withFetch(), // required for SSR โ€” uses native fetch
withInterceptors([authInterceptor]),
),
provideAuthInitializer(),
],
};

Step 7 โ€” SSR Wiringโ€‹

app.config.server.tsโ€‹

// src/app/app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { appConfig } from './app.config';
import { ssrCookieInterceptor } from './auth/ssr-cookie.interceptor';
import { authInterceptor } from './auth/auth.interceptor';

const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
// Override HttpClient for SSR: add the cookie-forwarding interceptor
provideHttpClient(
withFetch(),
withInterceptors([ssrCookieInterceptor, authInterceptor]),
),
],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

src/server.ts (Express wrapper for SSR)โ€‹

// src/server.ts  (generated by ng add @angular/ssr, shown for reference)
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './main.server';

export function app(): express.Express {
const server = express();
const __dir = dirname(fileURLToPath(import.meta.url));
const browser = resolve(__dir, '../browser');

server.set('view engine', 'html');
server.set('views', browser);
server.use(express.static(browser, { maxAge: '1y' }));

const engine = new CommonEngine();

server.get('**', (req, res, next) => {
engine
.render({
bootstrap,
documentFilePath: join(browser, 'index.html'),
url: req.originalUrl,
publicPath: browser,
providers: [
{ provide: APP_BASE_HREF, useValue: req.baseUrl },
// Pass the incoming request so the SSR cookie interceptor can forward cookies
{ provide: 'REQUEST', useValue: req },
{ provide: 'RESPONSE', useValue: res },
],
})
.then((html) => res.send(html))
.catch(next);
});

return server;
}

ssr-cookie.interceptor.ts โ€” forward browser cookies to the APIโ€‹

During SSR, withCredentials: true has no effect because there is no browser cookie jar. Instead, this interceptor reads the Cookie header from the incoming Express Request and forwards it to every outgoing API call, so the node-auth server receives the user's auth cookies correctly.

// src/app/auth/ssr-cookie.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject, PLATFORM_ID, InjectionToken } from '@angular/core';
import { isPlatformServer } from '@angular/common';

export const REQUEST = new InjectionToken<{ headers?: { cookie?: string } }>('REQUEST');

export const ssrCookieInterceptor: HttpInterceptorFn = (req, next) => {
const platformId = inject(PLATFORM_ID);
const request = inject(REQUEST, { optional: true });

if (!isPlatformServer(platformId) || !request) {
return next(req);
}

const cookieHeader = request.headers?.cookie;
if (!cookieHeader) return next(req);

// Forward the browser's cookies verbatim so node-auth can read them server-side
return next(req.clone({ setHeaders: { Cookie: cookieHeader } }));
};

Step 8 โ€” Page Componentsโ€‹

Loginโ€‹

// src/app/pages/login/login.component.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../../auth/auth.service';
import { NgIf } from '@angular/common';

@Component({
standalone: true,
selector: 'app-login',
imports: [ReactiveFormsModule, NgIf],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" type="email" placeholder="Email" autocomplete="email" />
<input formControlName="password" type="password" placeholder="Password" autocomplete="current-password" />
<p *ngIf="error" class="error">{{ error }}</p>
<button type="submit" [disabled]="form.invalid || loading">
{{ loading ? 'Signing inโ€ฆ' : 'Sign in' }}
</button>
</form>
`,
})
export default class LoginComponent {
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private fb = inject(FormBuilder);

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

loading = false;
error = '';

submit() {
if (this.form.invalid) return;
this.loading = true;
this.error = '';
const { email, password } = this.form.value;
this.auth.login(email!, password!).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] ?? '/dashboard';
this.router.navigateByUrl(returnUrl);
},
error: (e) => {
this.error = e.error?.message ?? 'Login failed';
this.loading = false;
},
});
}
}

Registerโ€‹

// src/app/pages/register/register.component.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { NgIf } from '@angular/common';

@Component({
standalone: true,
selector: 'app-register',
imports: [ReactiveFormsModule, NgIf],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="name" type="text" placeholder="Full name" />
<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]="form.invalid || loading">
{{ loading ? 'Creating accountโ€ฆ' : 'Create account' }}
</button>
</form>
`,
})
export default class RegisterComponent {
private http = inject(HttpClient);
private router = inject(Router);
private fb = inject(FormBuilder);

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

loading = false;
error = '';

submit() {
if (this.form.invalid) return;
this.loading = true;
this.http.post('/auth/register', this.form.value).subscribe({
next: () => this.router.navigateByUrl('/login'),
error: (e) => { this.error = e.error?.message ?? 'Registration failed'; this.loading = false; },
});
}
}
// src/app/components/navbar/navbar.component.ts
import { Component, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { AsyncPipe, NgIf } from '@angular/common';
import { AuthService } from '../../auth/auth.service';

@Component({
standalone: true,
selector: 'app-navbar',
imports: [RouterLink, AsyncPipe, NgIf],
template: `
<nav>
<a routerLink="/">Home</a>
<ng-container *ngIf="auth.user$ | async as user; else guestLinks">
<span>{{ user.email }}</span>
<button (click)="logout()">Logout</button>
</ng-container>
<ng-template #guestLinks>
<a routerLink="/login">Sign in</a>
<a routerLink="/register">Register</a>
</ng-template>
</nav>
`,
})
export class NavbarComponent {
readonly auth = inject(AuthService);
private router = inject(Router);

logout() {
this.auth.logout().subscribe(() => this.router.navigateByUrl('/login'));
}
}

Step 9 โ€” TOTP / 2FA flowโ€‹

When the server responds with { requiresTwoFactor: true, tempToken, available2faMethods } on login, store the tempToken and show the TOTP input. Then call /auth/2fa/verify with the TOTP code.

// auth.service.ts โ€” extend login() to handle 2FA
interface TwoFactorChallenge {
requiresTwoFactor: true;
tempToken: string;
available2faMethods: string[];
}

login(email: string, password: string): Observable<AuthUser | TwoFactorChallenge> {
return this.http
.post<{ requiresTwoFactor?: boolean; tempToken?: string; available2faMethods?: string[] }>(
'/auth/login', { email, password }, { withCredentials: true },
)
.pipe(
switchMap((res) => {
if (res.requiresTwoFactor) {
return of(res as TwoFactorChallenge);
}
// Cookies set by node-auth โ€” fetch the user profile
return this.fetchCurrentUser();
}),
);
}

verify2FA(tempToken: string, totpCode: string): Observable<AuthUser> {
return this.http
.post<void>('/auth/2fa/verify', { tempToken, totpCode }, { withCredentials: true })
.pipe(switchMap(() => this.fetchCurrentUser()));
}

Summary: what each piece doesโ€‹

FileRole
auth.service.tsSingle source of truth for user state; no token storage โ€” cookies are managed by the browser
auth.interceptor.tsAdds X-CSRF-Token header to state-changing requests; handles 401 with a refresh queue
auth.guard.tsauthGuard (login required), guestGuard (redirect if logged in), roleGuard(role) (RBAC), authCanMatch (lazy modules)
auth.initializer.tsAPP_INITIALIZER โ€” silent refresh on page reload
ssr-cookie.interceptor.tsSSR only: forwards the browser's Cookie header to the API server
app.config.tsRegisters provideHttpClient(withFetch(), withInterceptors([...]))
app.config.server.tsAdds provideServerRendering() and the SSR cookie interceptor
server.tsExpress wrapper โ€” injects REQUEST/RESPONSE tokens so the SSR interceptor can read cookies
SSR and absolute URLs

When Angular runs on the server it uses Node.js fetch which cannot resolve relative paths like /auth/login. Configure a base URL in the server config:

// app.config.server.ts โ€” add to providers:
{ provide: 'BASE_URL', useValue: 'http://localhost:4000' }

Then prefix all API calls in AuthService with this token when isPlatformServer() is true.