Cruising the Bytes: An Engineer's Expeditions - Episode 2: Build Once, Deploy Anywhere, Runtime Environment Variables in Angular with SSR June 20, 2026 | 30 min Read

Cruising the Bytes: An Engineer's Expeditions - Episode 2: Build Once, Deploy Anywhere, Runtime Environment Variables in Angular with SSR

Introduction

Welcome back to “Cruising the Bytes: An Engineer’s Expeditions”! For those of you who read Episode 1 and have been sitting by the harbour waiting for this ship to return, we appreciate your patience. Two years is admittedly a long dry-dock window, but in our defence, the sea was rough, the codebase was in good hands, and Angular managed to release approximately thirty major versions in the meantime. We are back now, thoroughly refuelled, and ready to set sail on a topic that has caused genuine headaches on real production systems.

In Episode 1, we set up a full-stack Single Page Application by wiring an Angular frontend directly to a Spring Boot backend. We covered project scaffolding, resource serving, Maven build integration, and end-to-end API communication. That foundation assumed that your application’s configuration, things like API URLs, feature toggles, and authentication credentials, would be known and baked in at build time. For a small project running in a single environment, that is perfectly fine. But the moment you need the same application to behave differently in development, staging, and production, or the moment a security requirement tells you that credentials must not live inside a JavaScript bundle that ships to the browser, the cracks begin to show.

That is exactly what this episode addresses. We will walk through ngx-configurable, a reference project that demonstrates how to make Angular applications externally configurable at runtime using Angular SSR, Node.js process.env, and Angular’s TransferState API. We will also tackle a bonus challenge that naturally falls out of this architecture: keeping OAuth2 credentials exclusively on the server, never in the browser, by implementing a server-side authenticated reverse proxy in Express.

The live demo is running at ngx-configurable.demo.reycoder.net and the full source is available on GitHub. Buckle up.


Why environment.ts Falls Short

Angular has always shipped with a mechanism for environment-specific configuration. You maintain a default file called environment.ts and one or more environment-specific counterparts, such as environment.prod.ts, and you register file replacement rules in angular.json. When you run ng build --configuration production, the Angular build system swaps the default file for the production variant before compiling, so the correct values are embedded into the output bundle.

This approach is elegant for what it is. The Angular toolchain handles the swap transparently, there are no runtime dependencies, and every environment’s configuration is colocated with the source code. For a project with one or two deployment targets and a team that controls the entire CI pipeline, this works well.

The trouble begins when the project grows. Consider these scenarios. Your organisation adopts a GitOps workflow where the same container image is promoted from development to staging to production. Every environment gets the identical Docker image, distinguished only by the environment variables injected at deployment time. Baking environment URLs into the bundle at build time directly conflicts with this model because you would need a separate build per environment, defeating the purpose of image promotion. Now consider a team that needs to rotate an OAuth2 client secret on short notice. With compile-time configuration, rotating that secret means editing a source file, committing, waiting for CI to compile, test, and publish a new image, and then deploying it. That is an enormous operational cost for what should be a thirty-second change. Finally, and most critically, consider what happens to the values in environment.ts once the build is complete. They are inlined into the JavaScript bundle that is served to every browser that visits your application. Any property in that file, including clientSecret, apiKey, or tokenUrl, is readable by anyone who opens the browser’s developer tools and searches the source.

The environment.ts pattern is not wrong. It solves a real problem within its intended scope. But its scope ends at the build step, and modern deployment practices demand configuration that lives at runtime.


The Architecture: Two Tiers of Configuration

Before we write a single line of code, it is worth spending time on the mental model, because getting this model right is what makes the rest of the implementation feel obvious rather than arbitrary.

The key insight is that not all configuration is equal. Some values are safe to expose to the browser. Things like the application’s display name, the current environment label, or a boolean flag that controls whether a beta banner is visible are values that the browser genuinely needs in order to render the UI correctly. There is no security risk in a user knowing that the application is running in a staging environment, or that the dark mode feature is currently disabled.

Other values must never reach the browser under any circumstances. OAuth2 client identifiers and secrets, the internal URL of a downstream API, and the IDP token endpoint are values that belong exclusively to the server-side process. Exposing them in the browser bundle, in the page source, or in any network response visible to DevTools would be a security vulnerability.

This gives us two tiers.

The first tier is browser-safe runtime configuration. These values are read from process.env on the Node.js server at request time, assembled into a typed object, and serialised into the initial HTML response using Angular’s TransferState API. When the Angular application boots in the browser, it reads this serialised object out of the HTML and makes it available through dependency injection. No additional HTTP request is needed. No process.env is accessed in browser code, which would not even work, since process is a Node.js concept.

The second tier is server-only secrets. These values are read from process.env exclusively inside Express middleware and are never passed to Angular’s rendering pipeline at all. They power a server-side authenticated proxy that intercepts browser requests to protected backend APIs, obtains OAuth2 Bearer tokens transparently, and forwards the requests with the correct authorisation header attached. The browser sends requests to /api/proxy/* and receives responses. It never sees a token, a client secret, or an IDP URL.

Here is a summary of where each configuration variable lives and how it travels.

Variable Tier How it reaches Angular
APP_NAME Browser-safe process.env on server, serialised via TransferState
APP_ENV Browser-safe process.env on server, serialised via TransferState
FEATURE_BETA_BANNER Browser-safe process.env on server, serialised via TransferState
FEATURE_DARK_MODE Browser-safe process.env on server, serialised via TransferState
CLIENT_ID Server-only Read by Express auth proxy only, never serialised
CLIENT_SECRET Server-only Read by Express auth proxy only, never serialised
TOKEN_URL Server-only Read by Express auth proxy only, never serialised
BACKEND_API_URL Server-only Read by Express auth proxy only, never serialised
API_SCOPE Server-only Read by Express auth proxy only, never serialised
ALLOWED_ORIGINS Server-only Read by Express CORS middleware only, never serialised

With this model established, let us walk through the implementation from the contract definition all the way to a running Kubernetes deployment.


Defining the Configuration Contract

The first thing we need is a shared TypeScript interface that describes the shape of the browser-safe configuration. This interface serves as the contract between the server, which populates the values, and the rest of the Angular application, which consumes them. Alongside the interface, we define two Angular-specific constructs: an InjectionToken that allows any component or service to request the configuration through Angular’s dependency injection system, and a TransferState key that identifies the serialised payload in the HTML.

File: src/app/app-config.token.ts

import { InjectionToken, makeStateKey } from '@angular/core';

export interface FeatureFlags {
  betaBanner: boolean;
  darkMode: boolean;
}

export interface AppConfig {
  appName: string;
  appEnv: string;
  featureFlags: FeatureFlags;
}

export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
export const APP_CONFIG_STATE_KEY = makeStateKey<AppConfig>('APP_CONFIG');

Here is what each piece of this file does.

FeatureFlags: A nested interface that groups the boolean feature toggle values together. Keeping them in a dedicated interface rather than spreading them flat on AppConfig gives us a clean namespace for future flags and makes the dependency injection payload self-documenting.

AppConfig: The root interface representing everything the browser is allowed to know about the runtime environment. Notice what is absent: there are no API URLs, no secrets, no token endpoints. This interface is intentionally narrow. Anything that is not safe to expose to the browser should not be here.

APP_CONFIG: An InjectionToken with the generic type AppConfig. This token acts as the key that Angular’s dependency injection container uses to locate and deliver the configuration object. By using an injection token rather than a concrete class, we keep the consumer code decoupled from any particular implementation. Components simply ask for APP_CONFIG and Angular delivers whatever the registered factory produces.

APP_CONFIG_STATE_KEY: A typed key created with makeStateKey. TransferState is Angular’s mechanism for passing data from a server-rendered response to the browser without requiring an extra HTTP round-trip. Think of it as a serialisation envelope embedded in the HTML. The server writes to this key, the HTML is delivered to the browser, and the browser reads from the same key during hydration. Using a typed key ensures that both the server writer and the browser reader agree on the shape of the data at compile time.


Populating Configuration on the Server

With the contract in place, we can now implement the server-side provider. Angular SSR runs inside a Node.js process, which means the server-side application config file has access to process.env, the standard way to read environment variables in Node.js. This is where we read every browser-safe value, assemble the AppConfig object, and write it into TransferState so that it travels to the browser inside the initial HTML response.

File: src/app/app.config.server.ts

import { mergeApplicationConfig, ApplicationConfig, TransferState } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
import { APP_CONFIG, APP_CONFIG_STATE_KEY, AppConfig } from './app-config.token';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(withRoutes(serverRoutes)),
    {
      provide: APP_CONFIG,
      useFactory: (transferState: TransferState): AppConfig => {
        const config: AppConfig = {
          appName: process.env['APP_NAME'] ?? 'ngx-configurable',
          appEnv: process.env['APP_ENV'] ?? 'development',
          featureFlags: {
            betaBanner: process.env['FEATURE_BETA_BANNER'] === 'true',
            darkMode: process.env['FEATURE_DARK_MODE'] === 'true',
          },
        };
        transferState.set(APP_CONFIG_STATE_KEY, config);
        return config;
      },
      deps: [TransferState],
    },
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Let us walk through this carefully, because there are a few subtle decisions that matter.

provideServerRendering(withRoutes(serverRoutes)): This registers the Angular SSR engine and associates it with the server-specific route definitions. Routes can be configured differently on the server, for example, to pre-render certain pages statically. We pass serverRoutes here to give the SSR engine that opportunity.

useFactory: Rather than providing a static value, we use a factory function so that TransferState can be injected as a dependency. The factory receives the TransferState instance, reads environment variables, builds the config, writes it to the transfer state, and returns it. The sequence matters: transferState.set must be called before the factory returns, otherwise the serialisation happens after Angular has already assembled the HTML response and the payload will be missing.

process.env['APP_NAME'] ?? 'ngx-configurable': The nullish coalescing operator ?? provides a fallback value for every variable. If APP_NAME is not set in the environment, the application continues to work with a sensible default rather than crashing. This makes the application resilient during local development where a full set of environment variables may not be configured.

process.env['FEATURE_BETA_BANNER'] === 'true': Environment variables are always strings. There is no native boolean in an environment variable. We explicitly compare against the string 'true' to convert to a boolean. Any other value, including an unset variable which becomes undefined, evaluates to false. This is intentional: feature flags should default to off, not on.

mergeApplicationConfig(appConfig, serverConfig): The exported config merges the base application config, which contains providers that run on both server and browser, with the server-specific additions. This merged config is what main.server.ts uses to bootstrap the SSR application.

Critically, notice what this factory does not include. CLIENT_ID, CLIENT_SECRET, TOKEN_URL, and BACKEND_API_URL are never assigned to the AppConfig object and are never passed to transferState.set. They exist in process.env on the server, but they are consumed by entirely separate Express middleware. The Angular rendering pipeline never sees them.


Receiving Configuration in the Browser

On the browser side, the application needs a provider for APP_CONFIG as well, but its job is the reverse: instead of writing to TransferState, it reads from it. This provider runs during Angular’s client-side hydration step, after the server-rendered HTML has arrived and Angular is bootstrapping in the browser.

File: src/app/app.config.ts

import {
  ApplicationConfig,
  TransferState,
  provideBrowserGlobalErrorListeners,
  provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch, withInterceptors, withNoXsrfProtection } from '@angular/common/http';
import { APP_CONFIG, APP_CONFIG_STATE_KEY, AppConfig } from './app-config.token';
import { csrfInterceptor } from './csrf.interceptor';

const devDefaults: AppConfig = {
  appName: 'ngx-configurable',
  appEnv: 'development',
  featureFlags: { betaBanner: false, darkMode: false },
};

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZonelessChangeDetection(),
    provideRouter(routes),
    provideClientHydration(withEventReplay()),
    provideHttpClient(
      withFetch(),
      withNoXsrfProtection(),
      withInterceptors([csrfInterceptor]),
    ),
    {
      provide: APP_CONFIG,
      useFactory: (transferState: TransferState): AppConfig =>
        transferState.get(APP_CONFIG_STATE_KEY, devDefaults),
      deps: [TransferState],
    },
  ],
};

Here is what each notable section contributes.

devDefaults: A constant that defines safe fallback values for every property on AppConfig. This is the configuration the browser falls back to when there is no TransferState payload available, which happens when running the application with ng serve during local development. Without this fallback, attempting to read APP_CONFIG in the browser would produce null and crash every component that depends on it.

transferState.get(APP_CONFIG_STATE_KEY, devDefaults): TransferState.get takes the typed key and a default value. On the first render of a page, when SSR was involved, the key will be present in the HTML payload and the server-populated values will be returned. On subsequent client-side navigations, or when SSR is bypassed, the default value applies. This makes the browser provider completely independent of whether SSR ran correctly.

provideZonelessChangeDetection(): Angular 20 ships with zoneless change detection as a production-ready option. Instead of relying on Zone.js to monkey-patch browser APIs and trigger change detection globally, the application uses Angular’s signal-based reactivity model. The result is a smaller bundle, more predictable rendering behaviour, and better performance.

withNoXsrfProtection() combined with csrfInterceptor: Angular’s built-in XSRF interceptor intentionally skips GET and HEAD requests, operating on the assumption that safe HTTP methods do not require CSRF protection. In this project, the Angular client makes GET requests to /api/proxy/data, which is a proxy that modifies server state by acquiring tokens and calling external APIs. We therefore disable the built-in interceptor and replace it with a custom one that attaches the X-XSRF-TOKEN header to every request, including GET. We will cover the CSRF mechanism in detail in a later section.


Consuming Configuration in Components and Templates

With both providers in place, any Angular component or service can receive the runtime configuration through standard dependency injection. There is no need to import environment files, no static globals, and no workarounds.

File: src/app/app.ts

import { Component, inject, signal } from '@angular/core';
import { NgClass, JsonPipe } from '@angular/common';
import { APP_CONFIG } from './app-config.token';
import { AppServiceService } from './app.service.service';

@Component({
  selector: 'app-root',
  imports: [NgClass, JsonPipe],
  templateUrl: './app.html',
})
export class App {
  protected readonly config = inject(APP_CONFIG);

  private readonly envBadgeClass: Record<string, string> = {
    development: 'bg-palette-604591-1 text-palette-604591-2',
    staging:     'bg-palette-604591-4 text-palette-604591-3',
    production:  'bg-palette-604591-5 text-white',
  };

  protected badgeClass(env: string): string {
    return this.envBadgeClass[env] ?? 'bg-gray-100 text-gray-500';
  }

  readonly dataStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
}

inject(APP_CONFIG): The modern Angular function-based injection API retrieves whatever value was registered under the APP_CONFIG token. On the server, this is the factory-built object populated from process.env. In the browser, it is the value hydrated from TransferState. The component code is identical in both environments and does not need to know where the values came from.

envBadgeClass: A lookup table that maps environment names to CSS class strings. The Tailwind CSS scanner requires that full class strings appear literally in the source code in order to include them in the production stylesheet. Computing class strings dynamically at runtime would cause them to be purged from the bundle. Declaring the full strings in a record here ensures they are always present.

signal: Angular’s Signals API is a fine-grained reactive primitive. Rather than triggering a full component re-render whenever anything changes, signals only notify subscribers when the specific value they track is updated. dataStatus is a writable signal that the component updates as an async data fetch progresses through its lifecycle.

The corresponding template uses Angular’s @if control flow syntax to conditionally render the beta banner based on the feature flag, and binds the environment badge class dynamically.

File: src/app/app.html (excerpt)

@if (config.featureFlags.betaBanner) {
  <div class="beta-banner">
    Beta environment — features may be unstable
  </div>
}

<header>
  <h1>{{ config.appName }}</h1>
  <span [ngClass]="badgeClass(config.appEnv)">{{ config.appEnv }}</span>
</header>

@if (config.featureFlags.betaBanner): Angular 17 introduced a new built-in control flow syntax that replaces the older *ngIf structural directive. The @if block is handled by the compiler rather than a directive, which means it has no additional runtime overhead and participates correctly in SSR without requiring any additional imports. The banner appears or disappears based on the FEATURE_BETA_BANNER environment variable, with no rebuild required.


Keeping Credentials Off the Browser: The Authenticated Proxy

We now arrive at the most architecturally significant part of this project. Understanding why it exists is just as important as understanding how it works, so let us build up the motivation carefully before looking at any code.

Our reference application needs to call a protected backend API, one that requires a valid OAuth2 Bearer token. In a traditional single-page application without SSR, the most common approaches to this problem are to have the user log in and acquire a token on their behalf (suitable for user-facing applications), or to embed a service account’s credentials in the JavaScript bundle and have the browser obtain a token directly (which is a security violation for any application handling anything sensitive).

The Angular SSR architecture unlocks a third approach. Because every request to the Angular application passes through a Node.js process before the browser receives a response, we can intercept API calls at the server, handle all token management there, and present the browser with a clean proxy endpoint that requires no credentials of its own. The browser calls /api/proxy/data. Express intercepts the call, retrieves a valid Bearer token from a cache (or fetches a new one from the IDP if the cache is empty), attaches it to a forwarded request to the real backend, and streams the response back to the browser.

The browser’s Network panel shows GET /api/proxy/data with no Authorization header outbound and a clean JSON response inbound. At no point does a token, a client identifier, or a token URL appear in any response visible to the browser.

The flow looks like this.

Browser  →  CSRF validation  →  Express auth proxy  →  Token cache  →  Backend API

Let us walk through each stage of that flow.

Issuing and Validating CSRF Tokens

Before any proxy call reaches the token cache or the backend, it must pass a CSRF check. The project implements the Double Submit Cookie pattern. On every HTTP response, including the initial page load, the server issues a cryptographically random token as a non-HttpOnly cookie named XSRF-TOKEN. The Angular client reads this cookie from document.cookie and attaches its value as an X-XSRF-TOKEN request header on every call to a relative URL. The Express middleware then compares the header value against the cookie value before allowing the request to proceed.

File: src/server/csrf.ts

import { randomBytes } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';

export function setCsrfCookie(req: Request, res: Response, next: NextFunction): void {
  const existing = parseCookies(req.headers['cookie'] ?? '')['XSRF-TOKEN'];
  if (!existing) {
    const token = randomBytes(32).toString('hex');
    res.cookie('XSRF-TOKEN', token, {
      httpOnly: false,
      sameSite: 'strict',
      secure: process.env['NODE_ENV'] === 'production',
      path: '/',
    });
  }
  next();
}

export function validateCsrf(req: Request, res: Response, next: NextFunction): void {
  const cookies = parseCookies(req.headers['cookie'] ?? '');
  const cookieToken = cookies['XSRF-TOKEN'];
  const headerToken = req.headers['x-xsrf-token'];

  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    res.status(403).json({ error: 'CSRF validation failed' });
    return;
  }
  next();
}

Here is why each design decision matters.

httpOnly: false: The XSRF-TOKEN cookie must be readable by JavaScript, because Angular’s csrfInterceptor needs to extract the token value from document.cookie and place it in a request header. Setting httpOnly: true would prevent that. This is intentional and expected in the double-submit pattern: the point is not that the cookie itself is secret, but that a cross-site attacker cannot read document.cookie on a different origin due to the browser’s same-origin policy.

sameSite: 'strict': This instructs the browser not to send the XSRF-TOKEN cookie on any cross-origin request, even a same-site top-level navigation. A CSRF attack originating from a third-party page cannot forge a request that carries the cookie, and without the cookie value, it cannot produce a matching header. The SameSite attribute closes the primary attack vector.

secure: process.env['NODE_ENV'] === 'production': The Secure attribute restricts the cookie to HTTPS connections. We gate it on NODE_ENV being production so that local development over plain HTTP still works without manual workarounds.

validateCsrf: The validation middleware compares the cookie value against the header value using strict equality. If either is absent, or if they do not match, the request is rejected with a 403 Forbidden response. A bare curl or REST client has no browser cookie jar and cannot reproduce both values simultaneously under normal circumstances.

The Angular side of this contract is equally important. The csrfInterceptor function runs as an HTTP interceptor registered in the application config. It reads XSRF-TOKEN from document.cookie, decodes it, and attaches it as an X-XSRF-TOKEN header on every request to a relative URL.

File: src/app/csrf.interceptor.ts

import { HttpInterceptorFn } from '@angular/common/http';
import { inject, PLATFORM_ID } from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';

export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
  if (!isPlatformBrowser(inject(PLATFORM_ID))) return next(req);
  if (req.url.startsWith('http')) return next(req);

  const document = inject(DOCUMENT);
  const tokenEntry = document.cookie
    .split(';')
    .map((c) => c.trim())
    .find((c) => c.startsWith('XSRF-TOKEN='));

  if (!tokenEntry) return next(req);

  const token = decodeURIComponent(tokenEntry.slice('XSRF-TOKEN='.length));
  return next(req.clone({ setHeaders: { 'X-XSRF-TOKEN': token } }));
};

isPlatformBrowser: The interceptor is registered for the entire application, which includes the server-side rendering context. On the server, document.cookie is empty and no CSRF cookie exists. This guard ensures the interceptor is a no-op during SSR, preventing unnecessary processing and avoiding errors.

req.url.startsWith('http'): The interceptor only attaches the CSRF header to relative URLs, that is, calls that are going to the same origin. If the application were to make a direct call to an external service using an absolute URL, attaching an X-XSRF-TOKEN header would be both unnecessary and potentially problematic. This guard keeps the interceptor narrowly scoped.

The Server-Side Token Cache

With CSRF protection in place, valid requests reach the token cache. The cache is a module-level singleton in Node.js: one shared token per server process, with automatic refresh handling and in-flight deduplication. It is important to understand why a cache is necessary. OAuth2 token endpoints are rate-limited and add latency. Without a cache, every single browser request to /api/proxy/* would trigger a new token fetch from the IDP before the request could be forwarded. Under any meaningful load, this would be prohibitively slow and would likely exceed the IDP’s rate limits.

File: src/server/token-cache.ts

interface TokenEntry {
  accessToken: string;
  expiresAt: number;
}

let cached: TokenEntry | null = null;
let inflight: Promise<string> | null = null;

export async function getAccessToken(
  tokenUrl: string,
  clientId: string,
  clientSecret: string,
  scope?: string,
): Promise<string> {
  if (cached && cached.expiresAt > Date.now() + 30_000) {
    return cached.accessToken;
  }

  if (inflight) return inflight;

  inflight = fetchToken(tokenUrl, clientId, clientSecret, scope).finally(() => {
    inflight = null;
  });

  return inflight;
}

async function fetchToken(
  tokenUrl: string,
  clientId: string,
  clientSecret: string,
  scope?: string,
): Promise<string> {
  const body = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: clientId,
    client_secret: clientSecret,
  });
  if (scope) body.set('scope', scope);

  const res = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });

  if (!res.ok) {
    throw new Error(`Token request failed [${res.status}]`);
  }

  const data = await res.json() as { access_token: string; expires_in?: number };
  const expiresIn = data.expires_in ?? 3600;
  cached = { accessToken: data.access_token, expiresAt: Date.now() + expiresIn * 1000 };

  return cached.accessToken;
}

Here is what makes this implementation robust under concurrent load.

cached.expiresAt > Date.now() + 30_000: The cache uses a thirty-second safety buffer before the token’s stated expiry time. OAuth2 tokens have an expires_in value measured in seconds, and we respect it by converting to milliseconds and storing an absolute expiry timestamp. The thirty-second buffer ensures we never attempt to use a token that is about to expire, accounting for network latency between our proxy and the backend.

if (inflight) return inflight: This is the deduplication logic. In a scenario where the cached token has expired and ten concurrent browser requests arrive simultaneously, all ten will enter getAccessToken and find no valid cached token. Without deduplication, all ten would issue a new token fetch to the IDP at the same time, producing ten concurrent HTTP requests and ten new tokens, of which nine would be immediately discarded. The inflight variable holds the pending Promise from the first token fetch. Every subsequent caller that arrives while that fetch is in-progress receives the same Promise reference and waits for it to resolve. The IDP receives exactly one request.

grant_type: 'client_credentials': This is the OAuth2 client credentials grant, the correct flow for server-to-server authentication where no user is involved. The client identifies itself using client_id and client_secret, receives a time-limited Bearer token, and uses that token to call protected APIs. The browser is entirely absent from this exchange.

The Authenticated Proxy Middleware

With a valid token available from the cache, the proxy middleware’s job is straightforward: strip any client-supplied Authorization header, attach the server-managed token, forward the request to the real backend, and stream the response back.

File: src/server/auth-proxy.ts

const BLOCKED_REQUEST_HEADERS = new Set([
  'host', 'connection', 'keep-alive', 'transfer-encoding',
  'te', 'trailers', 'upgrade', 'proxy-authenticate',
  'proxy-authorization', 'authorization',
]);

export async function authProxy(req: Request, res: Response, next: NextFunction): Promise<void> {
  const port = process.env['PORT'] || '4000';
  const tokenUrl     = process.env['TOKEN_URL']        || `http://localhost:${port}/mock/oauth/token`;
  const clientId     = process.env['CLIENT_ID']        || 'demo-client';
  const clientSecret = process.env['CLIENT_SECRET']    || 'demo-secret';
  const scope        = process.env['API_SCOPE'];
  const backendBase  = (process.env['BACKEND_API_URL'] || `http://localhost:${port}/mock/api`).replace(/\/$/, '');

  const token  = await getAccessToken(tokenUrl, clientId, clientSecret, scope);
  const target = `${backendBase}${req.url}`;

  const forwardHeaders: Record<string, string> = { Authorization: `Bearer ${token}` };
  for (const [key, value] of Object.entries(req.headers)) {
    if (!BLOCKED_REQUEST_HEADERS.has(key.toLowerCase()) && typeof value === 'string') {
      forwardHeaders[key] = value;
    }
  }

  const backendRes = await fetch(target, { method: req.method, headers: forwardHeaders });

  res.status(backendRes.status);
  for (const header of ['content-type', 'cache-control', 'x-request-id']) {
    const value = backendRes.headers.get(header);
    if (value) res.setHeader(header, value);
  }
  res.send(await backendRes.text());
}

BLOCKED_REQUEST_HEADERS: A set of header names that must never be forwarded from the browser to the backend. Hop-by-hop headers like connection, transfer-encoding, and keep-alive are specific to a single TCP connection and must not be propagated to a different connection. The authorization header is explicitly blocked because we are replacing it with the server-managed token. Allowing the client to influence the Authorization value would completely undermine the security model.

Reading from process.env at call time: All environment variable reads happen inside the function body rather than at module load time. This is a deliberate choice that ensures test overrides work correctly: a test can set process.env['TOKEN_URL'] to a mock endpoint and the proxy will use it on the next call. If the values were captured in module-level constants, they would be frozen at the time the module was first imported.

req.url relative to the mount point: Express mounts this middleware at /api/proxy, so req.url contains only the path segment after that prefix. A browser request to /api/proxy/data?page=2 arrives at this middleware with req.url equal to /data?page=2. Appending that to backendBase produces the correct downstream URL without any manual string manipulation.

The project also ships built-in mock endpoints for both the OAuth2 token exchange and the protected API, so the application runs correctly out of the box for local testing without requiring any external IDP or backend. When TOKEN_URL and BACKEND_API_URL are set in the environment, the proxy uses those real endpoints instead.


Wiring It All Together in the Express Server

The Express server file is where all the middleware is assembled into a coherent request handling pipeline. Understanding the order of middleware registration matters because Express executes middleware in the order it is registered, and the security properties of this application depend on that ordering.

File: src/server.ts

import { AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse } from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
import { authProxy } from './server/auth-proxy';
import { setCsrfCookie, validateCsrf } from './server/csrf';

const app = express();
const angularApp = new AngularNodeAppEngine();

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.use(setCsrfCookie);

const allowedOrigins = new Set(
  (process.env['ALLOWED_ORIGINS'] ?? '').split(',').filter(Boolean),
);

app.use('/api/proxy', (req, res, next) => {
  const origin = req.headers['origin'];
  if (origin && allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-XSRF-TOKEN');
    res.status(204).end();
    return;
  }
  next();
});

app.use('/api/proxy', validateCsrf, authProxy);

app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));

app.use((req, res, next) => {
  angularApp.handle(req)
    .then((response) => response ? writeResponseToNodeResponse(response, res) : next())
    .catch(next);
});

if (isMainModule(import.meta.url) || process.env['pm_id']) {
  const port = process.env['PORT'] || 4000;
  app.listen(port);
}

export const reqHandler = createNodeRequestHandler(app);

setCsrfCookie registered globally before any routes: The CSRF cookie must be issued before the Angular SSR handler runs, so that the cookie is present in the response headers for the very first page load. If a user visits the application for the first time and immediately clicks a button that calls /api/proxy/data, the cookie is already in their browser from that first response and the CSRF check will pass.

CORS middleware scoped to /api/proxy: Cross-origin resource sharing is only relevant for the proxy routes. The ALLOWED_ORIGINS environment variable accepts a comma-separated list of permitted origins. An empty value means same-origin only. The CORS headers are set dynamically per request based on whether the request’s Origin header matches the allowlist, which is the correct approach for supporting multiple origins without wildcarding.

validateCsrf, authProxy in sequence: Express passes the request through validateCsrf first. Only if that middleware calls next(), meaning the CSRF check passed, does the request reach authProxy. This sequencing ensures that unauthenticated requests cannot reach the token cache or the backend under any circumstances.

AngularNodeAppEngine: This is Angular’s official integration with Node.js for SSR. It receives raw Node.js IncomingMessage and ServerResponse objects, runs the Angular rendering pipeline, and writes the rendered HTML into the response. The writeResponseToNodeResponse helper bridges Angular’s internal Response type to Node.js’s native response object.

isMainModule: This guard ensures that the server only starts listening for connections when the file is executed directly, not when it is imported as a module. The export of reqHandler at the bottom supports serverless deployment environments that import the handler function rather than running the file as a standalone process.


Packaging the Application: The Dockerfile

With a working application, we need to package it in a way that honours the runtime configuration model. The Dockerfile uses a two-stage build: the first stage installs dependencies and compiles the TypeScript source, and the second stage copies only the compiled output into a clean production image.

File: Dockerfile

FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build

FROM node:22-alpine AS runner
WORKDIR /app
RUN npm install -g pm2

COPY --from=builder /app/dist ./dist
COPY pm2.config.js ./pm2.config.js

ENV NODE_ENV=production \
    PORT=4000 \
    APP_NAME=ngx-configurable \
    APP_ENV=production \
    FEATURE_BETA_BANNER=false \
    FEATURE_DARK_MODE=false

ENV CLIENT_ID=demo-client \
    CLIENT_SECRET=demo-secret \
    TOKEN_URL="" \
    API_SCOPE="" \
    BACKEND_API_URL="" \
    ALLOWED_ORIGINS=""

EXPOSE 4000
CMD ["pm2-runtime", "pm2.config.js"]

Here is what each stage contributes and why the structure matters.

Two-stage build: The builder stage installs all dependencies, including dev dependencies like the Angular CLI and TypeScript compiler, and produces the compiled output in /app/dist. The runner stage starts from a fresh node:22-alpine image and copies only dist and pm2.config.js. No source code, no node_modules, and no dev tooling enters the production image. This keeps the image small and eliminates an entire category of supply-chain risk from the runtime environment.

--frozen-lockfile: Yarn’s --frozen-lockfile flag rejects any dependency tree that does not exactly match yarn.lock. This ensures that the build is deterministic: the same lockfile always produces the same set of installed packages, regardless of when the build runs or what new versions may have been published since the lockfile was last updated.

Two ENV blocks: The first ENV block contains browser-safe configuration values with production-appropriate defaults. These are genuinely safe to bake into the image because they contain no secrets and their defaults are publicly visible in the open-source repository. The second ENV block handles server-only secrets. The values point to the built-in mock endpoints, which means the image works out of the box for local testing without any external dependencies. In a real deployment, these environment variables are overridden at runtime. The clear separation between the two blocks makes a code review straightforward: anyone can see at a glance which values are safe to default in the image and which require runtime injection.

pm2-runtime: PM2 is a Node.js process manager. pm2-runtime is the variant designed for containerised environments: it keeps PM2 itself in the foreground, which satisfies the Docker requirement that PID 1 be the process the container is monitoring. If PM2 backgrounded itself the way it does in daemon mode, Docker would see the container exit immediately.

A Critical Note About PM2 Configuration

File: pm2.config.js

module.exports = {
  apps: [
    {
      name: 'ngx-configurable-ssr',
      script: 'node',
      args: 'dist/ngx-configurable/server/server.mjs',
      instances: 1,
      exec_mode: 'fork',
    },
  ],
};

Notice what this file does not contain: there is no env: block. This is intentional and important. If you add an env: property to a PM2 app definition, PM2 applies those values after inheriting the container environment, silently overwriting any values injected by Docker or Kubernetes. This means that a deploy-time override of CLIENT_SECRET via docker run -e or a Kubernetes Secret would be overwritten by whatever PM2’s env: block specifies, and the override would have no effect.

By omitting env: entirely, PM2 inherits the container’s full environment unchanged. All configuration flows through a single, predictable channel: the deployment layer.

Also note exec_mode: 'fork'. PM2’s cluster mode, which forks multiple worker processes to distribute load, is incompatible with ES module files (.mjs). The compiled Angular SSR server is an ES module, so cluster mode would crash immediately. Fork mode runs a single process, which is appropriate for a containerised deployment where horizontal scaling is handled by the orchestration layer rather than within a single container.


Deploying to Kubernetes

Kubernetes is where this architecture delivers its full value. You maintain one container image and let the cluster inject the right configuration at deployment time. Browser-safe values go into a ConfigMap and secrets go into a Secret.

Kubernetes Secret for server-only credentials

apiVersion: v1
kind: Secret
metadata:
  name: ngx-configurable-credentials
type: Opaque
stringData:
  CLIENT_ID: "your-oauth2-client-id"
  CLIENT_SECRET: "your-oauth2-client-secret"
  TOKEN_URL: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
  BACKEND_API_URL: "https://api.yourbackend.example.com"

Kubernetes ConfigMap for browser-safe configuration

apiVersion: v1
kind: ConfigMap
metadata:
  name: ngx-configurable-config
data:
  APP_NAME: "My Application"
  APP_ENV: "production"
  FEATURE_BETA_BANNER: "false"
  FEATURE_DARK_MODE: "false"

Deployment fragment combining both sources

spec:
  containers:
    - name: ngx-configurable
      image: ngx-configurable:latest
      envFrom:
        - configMapRef:
            name: ngx-configurable-config
        - secretRef:
            name: ngx-configurable-credentials

With envFrom, Kubernetes injects every key-value pair from the referenced ConfigMap and Secret as environment variables into the container. Both sources arrive at the same process.env the application reads at startup. The application does not know or care whether a value came from a ConfigMap, a Secret, or a docker run -e flag. All it sees is process.env.

Kubernetes Secrets are base64-encoded by default rather than encrypted at rest, but they are kept separate from ConfigMap data, can be restricted by RBAC policies, and are supported by external secret management tools like HashiCorp Vault and AWS Secrets Manager. For production deployments, integrating one of these tools to populate the Secret dynamically is strongly recommended.


The Complete Environment Variable Reference

Here is the full set of environment variables the application respects, their tiers, their defaults, and their purposes.

Variable Tier Default Purpose
APP_NAME Browser-safe ngx-configurable Application display name shown in the page header
APP_ENV Browser-safe production Environment label used for the badge and beta banner logic
FEATURE_BETA_BANNER Browser-safe false When true, displays a beta warning banner across the top of the page
FEATURE_DARK_MODE Browser-safe false Feature flag for a dark mode UI variant
PORT Server-only 4000 The port the Node.js HTTP server listens on
NODE_ENV Server-only production Controls secure cookie settings and Express optimisations
CLIENT_ID Server-only demo-client OAuth2 client identifier used in the client credentials grant
CLIENT_SECRET Server-only demo-secret OAuth2 client secret, should be injected via a Kubernetes Secret
TOKEN_URL Server-only built-in mock IDP token endpoint for the client credentials flow
BACKEND_API_URL Server-only built-in mock Base URL of the downstream protected API
API_SCOPE Server-only (empty) OAuth2 scope string appended to the token request
ALLOWED_ORIGINS Server-only (empty) Comma-separated list of origins permitted for CORS on the proxy routes

Conclusion

We have covered a lot of ground in this episode. We started with a concrete critique of compile-time configuration, established a two-tier mental model for separating browser-safe values from server-only secrets, and then implemented that model end-to-end: from the InjectionToken contract through SSR-powered TransferState hydration, through an authenticated Express proxy with a CSRF guard and a token cache, all the way to a Docker image and Kubernetes manifests that wire it together in production.

The underlying principle is simple: the boundary between what the browser is allowed to know and what must remain on the server should be made explicit in code, enforced at the architecture level, and visible to anyone reading the project. A well-named interface with a deliberately narrow shape communicates that boundary more reliably than a comment in a configuration file ever could.

The full source for ngx-configurable is available at github.com/rey-coder/knwlb-angular-environment-config and the live demo is running at ngx-configurable.demo.reycoder.net, where you can inspect the Network panel and confirm that no credentials ever appear in a browser-visible request.

As we conclude this episode, our expedition is far from complete. There are more architectural shorelines to chart, more integration patterns to navigate, and, if the dry dock schedule allows it, hopefully a shorter gap before Episode 3. Fair winds and following seas await. Until next time, happy coding!

Reinhard O.T

Reinhard O.T

I’m a full-stack software engineer and DevOps enthusiast who transforms complex cloud architectures and CI/CD pipelines into …

comments powered by Disqus