Sharing Authentication Tokens Securely Across Remote Apps #

When a host and its Module Federation remotes run in separate execution contexts, the hard problem is handing every remote a valid, current authentication token without exposing it to XSS, leaking it into localStorage, or letting it drift out of sync when the host rotates it.

Module Federation fragments your runtime into independent boundaries that share no security context by default. The naive fixes — a token on window, an unencrypted BroadcastChannel, a raw value in localStorage — all trade correctness for convenience and hand an attacker a session. This guide builds a memory-only, read-only token bridge owned by the host and consumed by remotes through an immutable observable. It sits within the broader concerns of cross-app state and context sharing, and slots into the same middleware layer you use when synchronizing Redux across micro-frontends.

Secure token bridge data flow An HttpOnly cookie is synced server-side into a host-owned bridge, which exposes a frozen, read-only observable that remote apps subscribe to. HttpOnly Cookie Secure · SameSite /api/auth/sync Host Token Bridge memory-only singleton validate · rotate frozen observable Remote A read-only subscriber Remote B read-only subscriber Remote C read-only subscriber sync subscribe
The token never touches JS-readable storage: an HttpOnly cookie is synced into a host-owned bridge that pushes frozen values to read-only remote subscribers.

Prerequisites #

This guide assumes a working Module Federation setup and the following versions and assumptions:

If you have not yet wired host-to-remote state, read Synchronizing Redux Across Micro-Frontends first — the bridge below plugs into the same middleware seam.

Why Tokens Leak or Desynchronize Across Remote Boundaries #

Token failures across federated boundaries trace back to four distinct root causes. Knowing which one you are hitting determines the fix.

Step-by-Step: Building the Secure Token Bridge #

Eliminate localStorage and sessionStorage for tokens entirely. The durable source of truth is the HttpOnly, Secure, SameSite=Strict cookie; the in-page representation is a memory-only singleton that never persists to disk.

// src/auth/SecureTokenBridge.ts
import { BehaviorSubject, Observable, filter, map } from 'rxjs';

export interface TokenPayload {
  accessToken: string;
  expiresAt: number;
  refreshToken?: string;
}

export class SecureTokenBridge {
  private readonly _token$ = new BehaviorSubject<TokenPayload | null>(null);

  constructor() {
    // Hydrate from the HttpOnly cookie via a secure server-side endpoint.
    this.syncFromSecureCookie();
  }

  private syncFromSecureCookie(): void {
    // Never read raw tokens from document.cookie — the cookie is HttpOnly.
    fetch('/api/auth/sync', { credentials: 'include' })
      .then((res) => res.json())
      .then((data: TokenPayload) => this.updateToken(data))
      .catch(() => this._token$.next(null));
  }
}

Step 2 — Expose a strictly read-only bridge over Module Federation #

Share an accessor that returns a frozen object behind an observable, never a raw mutable reference. Remotes can read; they cannot mutate the host’s session.

// src/auth/SecureTokenBridge.ts (continued)
export class SecureTokenBridge {
  // ...fields and constructor from Step 1

  public getToken(): Observable<TokenPayload> {
    return this._token$.pipe(
      filter((token): token is TokenPayload => token !== null),
      map((token) => Object.freeze({ ...token })) // immutable to consumers
    );
  }
}

export const tokenBridge = new SecureTokenBridge();

Expose it from the host’s federation config and keep react and rxjs as singletons so the bridge instance is genuinely shared:

// webpack.config.ts (Host)
import { ModuleFederationPlugin } from 'webpack/lib/container/ModuleFederationPlugin';

export const config = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host_app',
      remotes: {
        remote_dashboard: 'remote_dashboard@https://cdn.example.com/remote/remoteEntry.js',
      },
      exposes: {
        './auth-bridge': './src/auth/SecureTokenBridge.ts',
      },
      shared: {
        react: { singleton: true, eager: false, requiredVersion: '^18.2.0' },
        rxjs: { singleton: true, eager: false, requiredVersion: '^7.8.0' },
      },
    }),
  ],
};

Step 3 — Validate, then broadcast token state with lifecycle hooks #

Validate every payload before it enters the stream, and schedule rotation off the refresh token. The validation guard enforces a clock-skew buffer so remotes never act on a token that is about to expire.

// src/auth/SecureTokenBridge.ts (continued)
export class SecureTokenBridge {
  // ...from previous steps

  public validateToken(token: TokenPayload): boolean {
    if (!token || !token.accessToken) return false;
    // 60-second buffer absorbs clock skew between host and remotes.
    return token.expiresAt > Date.now() + 60_000;
  }

  public updateToken(payload: TokenPayload | null): void {
    if (payload && !this.validateToken(payload)) {
      throw new Error('SecureTokenBridge: invalid or expired token payload.');
    }
    this._token$.next(payload);
    if (payload?.refreshToken) {
      this.scheduleRotation(payload);
    }
  }

  private scheduleRotation(_payload: TokenPayload): void {
    // Use AbortController + exponential backoff + a circuit breaker so a
    // failing refresh endpoint can never produce a runaway request loop.
  }
}

Because the bridge is a single observable, this is the same interception seam used for other shared state — the pattern carries over directly if you later swap stores, as covered in using Zustand for cross-micro-frontend state.

Step 4 — Consume the bridge in remotes with explicit loading states #

Remotes subscribe before making their first API call and render explicit loading and error states rather than throwing Promises into Suspense, which is brittle under federation.

// remote_app/src/AuthProvider.tsx
import React, { useEffect, useState } from 'react';
import type { Subscription } from 'rxjs';
// import { tokenBridge } from 'host_app/auth-bridge';

interface AuthProviderProps {
  children: React.ReactNode;
  tokenBridge: import('../../src/auth/SecureTokenBridge').SecureTokenBridge;
}

type AuthState =
  | { status: 'loading' }
  | { status: 'ready'; token: string }
  | { status: 'error'; message: string };

export const AuthProvider: React.FC<AuthProviderProps> = ({ children, tokenBridge }) => {
  const [authState, setAuthState] = useState<AuthState>({ status: 'loading' });

  useEffect(() => {
    let sub: Subscription | null = null;
    sub = tokenBridge.getToken().subscribe({
      next: (token) => {
        if (tokenBridge.validateToken(token)) {
          setAuthState({ status: 'ready', token: token.accessToken });
        }
      },
      error: (err: Error) => setAuthState({ status: 'error', message: err.message }),
    });
    return () => sub?.unsubscribe();
  }, [tokenBridge]);

  if (authState.status === 'loading') {
    return <div className="auth-skeleton">Initializing secure context...</div>;
  }
  if (authState.status === 'error') {
    return <div className="auth-error">Auth bridge failed: {authState.message}</div>;
  }
  return <>{children}</>;
};

Step 5 — Enforce validation and CSP at the mount boundary #

Validate JWT signatures and expiry before mounting a remote, and lock down headers so an injected chunk cannot exfiltrate the token.

# Response header from the host shell
Content-Security-Policy: default-src 'self';
  script-src 'self' https://cdn.example.com;
  connect-src 'self' https://auth.example.com;

Reject any remote that fails cryptographic validation or attempts unauthorized storage access during hydration.

Verification #

Confirm the bridge behaves correctly before promoting it. Each check has an observable signal you can assert on in CI or DevTools.

  1. No token in JS-readable storage. In DevTools, run localStorage and sessionStorage queries and inspect document.cookie. The access token must appear in none of them; the session cookie must show HttpOnly in the Application → Cookies panel.

  2. Frozen payloads. In a remote, attempt to mutate a received token and assert it throws:

tokenBridge.getToken().subscribe((t) => {
  // @ts-expect-error — verifying immutability
  expect(() => { t.accessToken = 'tampered'; }).toThrow(TypeError);
});
  1. Atomic refresh under load. Throttle to a slow 3G profile, trigger a refresh, and assert remotes receive exactly one updated value with no duplicate API calls:
# Expected log signature on rotation
[bridge] token rotated exp=1718900000 subscribers=3 duplicate_calls=0
  1. No stale 401s. After a host-side rotation, confirm in the Network panel that subsequent remote requests carry the new Authorization header and return 200.

Troubleshooting #

Symptom: remotes get 401s immediately after mount. Diagnosis: the remote fired its first request before the getToken() subscription resolved — a lifecycle race. Fix: gate the remote’s API client and routing behind the AuthProvider ready state so no request is issued until the first valid token is received.

Symptom: token is null in remotes but valid in the host. Diagnosis: react/rxjs are not actually shared as singletons, so the remote instantiated its own SecureTokenBridge that never ran syncFromSecureCookie. Fix: confirm both are singleton: true on host and remote with compatible requiredVersion, and verify in the Module Federation runtime that only one shared instance is registered.

Symptom: refresh storms — the auth endpoint sees a burst of calls. Diagnosis: scheduleRotation lacks backoff or a circuit breaker, so a transient failure retries unbounded. Fix: wrap rotation in an AbortController with exponential backoff and trip a circuit breaker after three consecutive failures, halting remote requests and surfacing a maintenance state.

Symptom: cross-origin remote cannot reach /api/auth/sync. Diagnosis: connect-src in the CSP, or the cookie’s SameSite=Strict, blocks the cross-origin credentialed request. Fix: serve the sync endpoint from the host’s own origin, keep credentials: 'include', and confirm connect-src lists every origin the bridge talks to.

Rollback and Graceful Degradation #

Distributed auth must tolerate partial failure without dropping sessions.