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.
Prerequisites #
This guide assumes a working Module Federation setup and the following versions and assumptions:
- Webpack 5.80+ (or
@module-federation/vite) with a host that already loads at least one remote. - React 18.2+ and RxJS 7.8+, both declared as shared singletons so the bridge and its observable are not duplicated per remote.
- A server-side auth endpoint (
/api/auth/sync) that reads theHttpOnly,Secure,SameSite=Strictsession cookie and returns the current access token plus its expiry. The token must never be readable fromdocument.cookie. - A Content-Security-Policy you control, so
connect-srccan be tightened around the sync and refresh endpoints.
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.
- Browser security boundaries. Same-Origin Policy conflicts with cross-origin remote chunk loading. Cookie or storage sharing breaks when remotes execute under isolated origins or partitioned storage.
- State fragmentation. Remotes initialize their auth context at different lifecycle stages, so a stale read can happen before an HTTP interceptor attaches its
Authorizationheader — producing cascading 401s. - Anti-pattern exposure. Unencrypted
BroadcastChannel, direct DOM mutation, or a mutable singleton lets a compromised remote or third-party script intercept or rewrite the live session. - Lifecycle mismatch. The host refreshes a short-lived JWT while a mounted remote still holds the expired one in memory. Without one invalidation signal, remote calls fail silently or spin uncoordinated retry loops.
Step-by-Step: Building the Secure Token Bridge #
Step 1 — Centralize storage in an HttpOnly cookie plus a memory-only singleton #
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.
-
No token in JS-readable storage. In DevTools, run
localStorageandsessionStoragequeries and inspectdocument.cookie. The access token must appear in none of them; the session cookie must showHttpOnlyin the Application → Cookies panel. -
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);
});
- 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
- No stale 401s. After a host-side rotation, confirm in the Network panel that subsequent remote requests carry the new
Authorizationheader 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.
- Cookie-only fallback. If the bridge fails to initialize, degrade to standard session-cookie auth and bypass remote chunk loading until it recovers.
- Feature-flag kill switch. Keep a runtime flag (
ENABLE_SECURE_TOKEN_BRIDGE) to revert to legacy propagation instantly, without redeploying. - APM alerting. Track desync rate, 401 spike frequency, and remote-mount failures; alert when desync exceeds 0.5% over five minutes.
- Deterministic cleanup. On rollback, clear orphaned in-memory tokens, reset the auth slice, and force a clean re-authentication so no stale credential lingers.