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, andcanMatchGuardAPP_INITIALIZERfor silent session restore on page reload- Full SSR wiring that forwards browser cookies to the API server
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; },
});
}
}
Navbar with logoutโ
// 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โ
| File | Role |
|---|---|
auth.service.ts | Single source of truth for user state; no token storage โ cookies are managed by the browser |
auth.interceptor.ts | Adds X-CSRF-Token header to state-changing requests; handles 401 with a refresh queue |
auth.guard.ts | authGuard (login required), guestGuard (redirect if logged in), roleGuard(role) (RBAC), authCanMatch (lazy modules) |
auth.initializer.ts | APP_INITIALIZER โ silent refresh on page reload |
ssr-cookie.interceptor.ts | SSR only: forwards the browser's Cookie header to the API server |
app.config.ts | Registers provideHttpClient(withFetch(), withInterceptors([...])) |
app.config.server.ts | Adds provideServerRendering() and the SSR cookie interceptor |
server.ts | Express wrapper โ injects REQUEST/RESPONSE tokens so the SSR interceptor can read cookies |
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.