Skip to content
Ayhan Sipahi Ayhan Sipahi

Auth0 Multiple Audiences: Token Management in Micro Frontends

Auth0 multi-audience authentication across micro frontends: token management strategies and silent authentication in React Native with WebView-based apps.

An Auth0 access token carries exactly one audience. A micro frontend estate that talks to three separate APIs therefore needs three tokens, and the naive reading of that constraint is three logins. It does not have to be. Keep a single Auth0 session in the shell application. The shell trades that session for a per-audience access token on demand and passes tokens to each micro frontend over an origin-checked postMessage channel. The alternatives (one Auth0 application per micro frontend, one wide audience with fine-grained scopes, a server-side exchange service) all cost more and give back less.

The browser half is straightforward. React Native is where it gets awkward: the same micro frontends load inside WebViews that cannot reliably reach the Auth0 session cookie, so the native layer has to own the tokens and answer WebView requests for them.

The Single-Audience Constraint

Consider this distributed system architecture:

React Native

Backend APIs

Micro Frontends

Shell Application

User

Shell App auth.myapp.com

Token Manager

Billing MFE billing.myapp.com

Dashboard MFE dashboard.myapp.com

Analytics MFE analytics.myapp.com

Billing API Audience: billing-api

Core API Audience: core-api

Analytics API Audience: analytics-api

React Native App

WebView: Billing

WebView: Dashboard

Each API validates a different aud claim, and one /authorize round trip yields a token for one audience only. A token minted for the billing API is rejected by the analytics API, and the reverse. Without a coordination layer, that is one interactive login per API.

The system also has to work inside React Native WebViews, where the usual web patterns break down on cookie restrictions and cross-origin limits.

Alternatives and Their Limits

Three arrangements look reasonable on paper and cost more than a shell broker in practice:

Separate Auth0 applications per micro frontend

Idea: each micro frontend gets its own Auth0 application and audience. Limit: users still authenticate per application, and configuration drift grows faster than the estate. Cross-application session sharing depends on cookie behavior you do not control.

One audience with fine-grained scopes

Idea: a single audience covering every API, with scopes gating access. Limit: each API loses the ability to reject a token meant for another API, because aud no longer distinguishes them. Scope ownership blurs across teams.

Server-side token exchange

Idea: a backend service swaps the incoming token for an audience-specific one. Limit: it adds a network hop to every audience switch, needs a change in every service that consumes it, and duplicates work the Auth0 session already does.

Coordinated Multi-Audience Token Management

The shell application owns the Auth0 client and orchestrates authentication for every micro frontend:

1. The Token Manager Architecture

// token-manager.ts - Core token management implementation
import { Auth0Client } from '@auth0/auth0-spa-js'; // ^2.1.3

interface TokenSet {
  accessToken: string;
  expiresAt: number;
  audience: string;
  scope: string;
}

class MultiAudienceTokenManager {
  private tokens: Map<string, TokenSet> = new Map();
  // Set only by the React Native bridge, which holds the refresh token itself.
  // In the browser the SPA SDK keeps it out of reach and this stays null.
  private primaryRefreshToken: string | null = null;
  private auth0Client: Auth0Client;

  constructor(private config: Auth0Config) {
    this.auth0Client = new Auth0Client({
      domain: config.domain,
      clientId: config.clientId,
      cacheLocation: 'memory', // Critical for micro frontends
      useRefreshTokens: true,
      authorizeTimeoutInSeconds: 60
    });
  }

  // One interactive login, requesting every scope the estate needs.
  // This navigates away; execution resumes in completeLogin() on the callback route.
  async startLogin(primaryAudience: string): Promise<void> {
    await this.auth0Client.loginWithRedirect({
      authorizationParams: {
        audience: primaryAudience,
        scope: this.getAllRequiredScopes(),
        redirect_uri: window.location.origin
      }
    });
  }

  // Runs on the callback route, then warms the remaining audiences
  async completeLogin(audiences: string[]): Promise<void> {
    await this.auth0Client.handleRedirectCallback();

    for (const audience of audiences) {
      await this.getTokenForAudience(audience);
    }
  }

  async getTokenForAudience(audience: string): Promise<string> {
    // Check cache first
    const cached = this.tokens.get(audience);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.accessToken;
    }

    const scope = this.getScopeForAudience(audience);

    try {
      // The SDK reuses the existing session for the new audience
      const result = await this.auth0Client.getTokenSilently({
        authorizationParams: { audience, scope },
        detailedResponse: true // Gives expires_in instead of a bare string
      });

      this.storeToken(audience, {
        accessToken: result.access_token,
        expiresAt: Date.now() + result.expires_in * 1000,
        audience,
        scope
      });

      return result.access_token;
    } catch (error) {
      // Silent auth fails when the session is gone or third-party cookies are blocked
      if (this.primaryRefreshToken) {
        return this.refreshTokenForAudience(audience);
      }
      throw error;
    }
  }

  // Direct refresh_token grant, for the case where the app owns the refresh
  // token itself. That is the React Native bridge, not the browser.
  private async refreshTokenForAudience(audience: string): Promise<string> {
    const scope = this.getScopeForAudience(audience);

    const response = await fetch(`https://${this.config.domain}/oauth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'refresh_token',
        client_id: this.config.clientId,
        refresh_token: this.primaryRefreshToken,
        audience,
        scope
      })
    });

    if (!response.ok) {
      throw new Error(`Refresh failed for ${audience}: ${response.status}`);
    }

    const data = await response.json();

    this.storeToken(audience, {
      accessToken: data.access_token,
      expiresAt: Date.now() + data.expires_in * 1000,
      audience,
      scope: data.scope
    });

    return data.access_token;
  }
}

2. Cross-Domain Token Sharing for Micro Frontends

The hard part is sharing tokens across subdomains. Three fallbacks, tried in order:

// shared-auth-context.tsx - Used by all micro frontends
import { createContext, useContext, useEffect, useState } from 'react';

interface SharedAuthState {
  isAuthenticated: boolean;
  tokens: Map<string, string>;
  user: any;
}

const SharedAuthContext = createContext<SharedAuthState | null>(null);

// Broadcast channel for cross-tab/cross-iframe communication
const authChannel = new BroadcastChannel('auth-sync');

export function SharedAuthProvider({ children, audience }: Props) {
  const [authState, setAuthState] = useState<SharedAuthState>();
  const [tokenManager] = useState(() => new MultiAudienceTokenManager(auth0Config));

  useEffect(() => {
    // Listen for auth updates from other micro frontends
    authChannel.onmessage = (event) => {
      if (event.data.type === 'AUTH_UPDATE') {
        setAuthState(event.data.payload);
      }
    };

    // Check if we're authenticated via shared storage
    checkSharedAuthentication();
  }, []);

  const checkSharedAuthentication = async () => {
    // Try multiple storage strategies

    // Strategy 1: Shared localStorage via iframe postMessage
    const sharedToken = await getTokenFromShell();

    // Strategy 2: Server-side session check
    if (!sharedToken) {
      const session = await checkServerSession();
      if (session) {
        await silentAuthentication();
      }
    }

    // Strategy 3: Auth0 session check
    if (!sharedToken) {
      const auth0Session = await checkAuth0Session();
      if (auth0Session) {
        await getTokenSilently();
      }
    }
  };

  const getTokenFromShell = (): Promise<string | null> => {
    return new Promise((resolve) => {
      // Post message to shell application
      window.parent.postMessage(
        { type: 'GET_TOKEN', audience },
        'https://auth.myapp.com'
      );

      // Listen for response
      const handler = (event: MessageEvent) => {
        if (event.origin !== 'https://auth.myapp.com') return;
        if (event.data.type === 'TOKEN_RESPONSE') {
          window.removeEventListener('message', handler);
          resolve(event.data.token);
        }
      };

      window.addEventListener('message', handler);

      // Timeout after 1 second
      setTimeout(() => {
        window.removeEventListener('message', handler);
        resolve(null);
      }, 1000);
    });
  };

  return (
    <SharedAuthContext.Provider value={authState}>
      {children}
    </SharedAuthContext.Provider>
  );
}

3. The Shell Application - Orchestrating Authentication

// shell-application.tsx - The authentication orchestrator
class ShellAuthOrchestrator {
  private microFrontends: Map<string, MicroFrontendConfig> = new Map();
  private tokenManager: MultiAudienceTokenManager;
  private sessionManager: SessionManager;

  async initialize() {
    // Register all micro frontends and their required audiences
    this.registerMicroFrontends([
      {
        name: 'billing',
        url: 'https://billing.myapp.com',
        audience: 'https://api.myapp.com/billing',
        scopes: ['read:invoices', 'write:payments']
      },
      {
        name: 'dashboard',
        url: 'https://dashboard.myapp.com',
        audience: 'https://api.myapp.com/core',
        scopes: ['read:profile', 'read:data']
      },
      {
        name: 'analytics',
        url: 'https://analytics.myapp.com',
        audience: 'https://api.myapp.com/analytics',
        scopes: ['read:reports', 'read:metrics']
      }
    ]);

    // Setup message handler for micro frontend token requests
    window.addEventListener('message', this.handleTokenRequest);

    // Check authentication status
    await this.checkAuthentication();
  }

  private handleTokenRequest = async (event: MessageEvent) => {
    // Validate origin
    const mfe = this.getMicroFrontendByOrigin(event.origin);
    if (!mfe) return;

    if (event.data.type === 'GET_TOKEN') {
      const token = await this.tokenManager.getTokenForAudience(
        event.data.audience
      );

      // Send token back to requesting micro frontend
      event.source?.postMessage(
        {
          type: 'TOKEN_RESPONSE',
          token: token,
          audience: event.data.audience
        },
        event.origin
      );
    }
  };

  async performLogin() {
    // Collect all required audiences
    const audiences = Array.from(this.microFrontends.values())
      .map(mfe => mfe.audience);

    // Single interactive login; the browser leaves the page here
    await this.tokenManager.startLogin(audiences[0]);
  }

  // Called from the callback route once Auth0 redirects back
  async finishLogin(audiences: string[]) {
    await this.tokenManager.completeLogin(audiences);

    // Notify all micro frontends
    this.broadcastAuthUpdate();
  }

  private broadcastAuthUpdate() {
    const authChannel = new BroadcastChannel('auth-sync');
    authChannel.postMessage({
      type: 'AUTH_UPDATE',
      payload: {
        isAuthenticated: true,
        user: this.tokenManager.getUser()
      }
    });
  }
}

The Token Refresh Strategy

Refresh is awkward here because several frames can notice the same expiring token in the same instant. One coordinator, one in-flight promise per audience:

// token-refresh-coordinator.ts
import { jwtDecode } from 'jwt-decode'; // ^4.0.0 dropped the default export

class TokenRefreshCoordinator {
  private refreshPromises: Map<string, Promise<string>> = new Map();
  private refreshTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();

  setupAutoRefresh(audience: string, expiresIn: number) {
    // Clear existing timer
    const existingTimer = this.refreshTimers.get(audience);
    if (existingTimer) clearTimeout(existingTimer);

    // Refresh 5 minutes before expiry
    const refreshIn = (expiresIn - 300) * 1000;

    const timer = setTimeout(() => {
      this.refreshToken(audience);
    }, refreshIn);

    this.refreshTimers.set(audience, timer);
  }

  async refreshToken(audience: string): Promise<string> {
    // Prevent concurrent refresh for same audience
    const existing = this.refreshPromises.get(audience);
    if (existing) return existing;

    const refreshPromise = this.performRefresh(audience);
    this.refreshPromises.set(audience, refreshPromise);

    try {
      const token = await refreshPromise;
      return token;
    } finally {
      this.refreshPromises.delete(audience);
    }
  }

  private async performRefresh(audience: string): Promise<string> {
    try {
      // Try silent refresh first. cacheMode: 'off' replaced v1's ignoreCache.
      const token = await auth0Client.getTokenSilently({
        authorizationParams: { audience },
        cacheMode: 'off'
      });

      // Decode to get expiry
      const decoded = jwtDecode<{ exp: number }>(token);
      const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);

      // Setup next refresh
      this.setupAutoRefresh(audience, expiresIn);

      // Update storage
      this.updateTokenStorage(audience, token);

      // Notify micro frontends
      this.notifyTokenRefresh(audience, token);

      return token;
    } catch (error) {
      console.error(`Token refresh failed for ${audience}:`, error);

      // If refresh fails, try re-authentication
      if (error.error === 'login_required') {
        await this.handleLoginRequired();
      }

      throw error;
    }
  }

  private notifyTokenRefresh(audience: string, token: string) {
    // Notify via BroadcastChannel
    const channel = new BroadcastChannel('auth-sync');
    channel.postMessage({
      type: 'TOKEN_REFRESHED',
      audience: audience,
      token: token
    });

    // Notify iframes. Never post a token with targetOrigin '*'.
    document.querySelectorAll('iframe').forEach(iframe => {
      const target = new URL(iframe.src, window.location.href).origin;
      if (!ALLOWED_MFE_ORIGINS.includes(target)) return;

      iframe.contentWindow?.postMessage(
        { type: 'TOKEN_REFRESHED', audience, token },
        target
      );
    });
  }
}

Auth0 Actions for Multi-Audience Support

Rules and Hooks are deprecated in favor of Actions, and new tenants only get Actions. The post-login Action is where audience-specific claims belong:

// auth0-action.js - Add custom claims for all audiences using Actions
exports.onExecutePostLogin = async (event, api) => {
  const { user, request } = event;

  // Define audience-specific permissions
  const audiencePermissions = {
    'https://api.myapp.com/billing': ['read:invoices', 'write:payments'],
    'https://api.myapp.com/core': ['read:profile', 'read:data'],
    'https://api.myapp.com/analytics': ['read:reports', 'read:metrics']
  };

  // Check which audience is being requested
  const requestedAudience = request.query?.audience || request.body?.audience;

  // Add namespace to avoid collision
  const namespace = 'https://myapp.com/';

  // Add user metadata to all tokens
  api.accessToken.setCustomClaim(namespace + 'email', user.email);
  api.accessToken.setCustomClaim(namespace + 'roles', user.app_metadata?.roles || []);

  // Add audience-specific permissions
  if (audiencePermissions[requestedAudience]) {
    api.accessToken.setCustomClaim(namespace + 'permissions', audiencePermissions[requestedAudience]);
  }

  // Add refresh token indicator for primary audience only
  if (requestedAudience === 'https://api.myapp.com/core') {
    api.accessToken.setCustomClaim(namespace + 'can_refresh', true);
  }
};

Silent Authentication in the Browser

prompt=none inside a hidden iframe is the mechanism that turns one session into many audience tokens. @auth0/auth0-spa-js already does this internally, so reach for the SDK first. The manual version is worth reading because its failure modes are the ones you end up debugging:

// silent-auth-handler.ts
class SilentAuthHandler {
  private iframe: HTMLIFrameElement | null = null;
  private pendingVerifier: string | null = null;
  private timeoutMs = 60000; // 60 seconds

  async performSilentAuth(options: SilentAuthOptions): Promise<TokenSet> {
    // Create hidden iframe for silent auth
    this.iframe = this.createAuthIframe();

    const authUrl = await this.buildAuthUrl(options);

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        this.cleanup();
        reject(new Error('Silent authentication timeout'));
      }, this.timeoutMs);

      // Listen for auth response
      const handleMessage = (event: MessageEvent) => {
        if (event.origin !== `https://${AUTH0_DOMAIN}`) return;

        clearTimeout(timeout);

        if (event.data.type === 'authorization_response') {
          this.handleAuthResponse(event.data, options)
            .then(resolve)
            .catch(reject)
            .finally(() => this.cleanup());
        }

        if (event.data.type === 'authorization_error') {
          this.cleanup();
          reject(new Error(event.data.error));
        }
      };

      window.addEventListener('message', handleMessage);

      // Navigate iframe to auth URL
      this.iframe.src = authUrl;
    });
  }

  private createAuthIframe(): HTMLIFrameElement {
    const iframe = document.createElement('iframe');
    iframe.style.display = 'none';
    iframe.style.visibility = 'hidden';
    iframe.style.position = 'fixed';
    iframe.style.width = '0';
    iframe.style.height = '0';
    document.body.appendChild(iframe);
    return iframe;
  }

  private async buildAuthUrl(options: SilentAuthOptions): Promise<string> {
    // Authorization code + PKCE. The implicit grant is off by default on
    // current Auth0 applications, so response_type=token would be rejected.
    this.pendingVerifier = this.createCodeVerifier();

    const params = new URLSearchParams({
      client_id: AUTH0_CLIENT_ID,
      response_type: 'code',
      code_challenge: await this.sha256UrlSafe(this.pendingVerifier),
      code_challenge_method: 'S256',
      redirect_uri: `${window.location.origin}/silent-callback.html`,
      audience: options.audience,
      scope: options.scope,
      state: this.generateState(),
      nonce: this.generateNonce(),
      prompt: 'none', // Critical for silent auth
      response_mode: 'web_message' // Use postMessage
    });

    return `https://${AUTH0_DOMAIN}/authorize?${params}`;
  }

  private async handleAuthResponse(
    response: any,
    options: SilentAuthOptions
  ): Promise<TokenSet> {
    // Validate state before touching the code
    if (!this.validateState(response.state)) {
      throw new Error('State validation failed');
    }

    // Exchange the authorization code for tokens
    const res = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        client_id: AUTH0_CLIENT_ID,
        code: response.code,
        code_verifier: this.pendingVerifier,
        redirect_uri: `${window.location.origin}/silent-callback.html`
      })
    });

    if (!res.ok) {
      throw new Error(`Code exchange failed: ${res.status}`);
    }

    const data = await res.json();

    return {
      accessToken: data.access_token,
      expiresAt: Date.now() + data.expires_in * 1000,
      audience: options.audience,
      scope: data.scope
    };
  }

  private cleanup() {
    if (this.iframe && this.iframe.parentNode) {
      this.iframe.parentNode.removeChild(this.iframe);
      this.iframe = null;
    }
  }
}

React Native with WebView Micro Frontends

Inside React Native the same micro frontends load in WebViews, and a WebView cannot count on seeing the Auth0 session cookie the native layer just established. So the native side owns the tokens and the WebView asks for them over a message bridge:

// react-native-auth-bridge.tsx
import React, { useRef, useState } from 'react';
import { WebView } from 'react-native-webview'; // ^13.8.6
import AsyncStorage from '@react-native-async-storage/async-storage'; // ^1.23.1
import { authorize, refresh } from 'react-native-app-auth'; // ^7.1.0

interface AuthBridge {
  webViewRef: React.RefObject<WebView>;
  tokens: Map<string, string>;
}

export function AuthenticatedMicroFrontend({ url, audience }: Props) {
  const webViewRef = useRef<WebView>(null);
  const [tokens, setTokens] = useState<Map<string, string>>(new Map());

  // react-native-app-auth config - chosen for its Auth0 compatibility
  // and native authentication session support on iOS/Android
  const auth0Config = {
    issuer: `https://${AUTH0_DOMAIN}`,
    clientId: AUTH0_CLIENT_ID,
    redirectUrl: 'com.myapp://auth/callback',
    scopes: ['openid', 'profile', 'email', 'offline_access'],
    additionalParameters: {
      audience: audience
    }
  };

  // Native authentication
  const performNativeAuth = async () => {
    try {
      // Use react-native-app-auth for native Auth0 flow
      const result = await authorize(auth0Config);

      // Store tokens
      await AsyncStorage.setItem('auth_tokens', JSON.stringify({
        accessToken: result.accessToken,
        idToken: result.idToken,
        refreshToken: result.refreshToken,
        expiresAt: new Date(result.accessTokenExpirationDate).getTime()
      }));

      // Get tokens for other audiences if needed
      await getMultipleAudienceTokens(result.refreshToken);

      return result;
    } catch (error) {
      console.error('Native auth failed:', error);
      throw error;
    }
  };

  // Bridge between React Native and WebView
  const injectedJavaScript = `
    (function() {
      // Override Auth0 client to use native bridge
      window.nativeAuth = {
        getToken: function(audience) {
          return new Promise((resolve, reject) => {
            // Generate unique request ID
            const requestId = Math.random().toString(36).substr(2, 9);

            // Setup response handler
            window.handleTokenResponse = function(id, token, error) {
              if (id !== requestId) return;

              if (error) {
                reject(new Error(error));
              } else {
                resolve(token);
              }

              delete window.handleTokenResponse;
            };

            // Request token from React Native
            window.ReactNativeWebView.postMessage(JSON.stringify({
              type: 'GET_TOKEN',
              audience: audience,
              requestId: requestId
            }));
          });
        },

        silentAuth: function(options) {
          return new Promise((resolve, reject) => {
            window.ReactNativeWebView.postMessage(JSON.stringify({
              type: 'SILENT_AUTH',
              options: options
            }));

            window.handleSilentAuthResponse = function(result, error) {
              if (error) {
                reject(error);
              } else {
                resolve(result);
              }
              delete window.handleSilentAuthResponse;
            };
          });
        }
      };

      // Intercept Auth0 client initialization
      if (window.createAuth0Client) {
        const originalCreate = window.createAuth0Client;
        window.createAuth0Client = async function(config) {
          // Return mock client that uses native bridge
          return {
            getTokenSilently: async (options) => {
              return window.nativeAuth.getToken(options.audience);
            },
            loginWithRedirect: async () => {
              window.ReactNativeWebView.postMessage(JSON.stringify({
                type: 'LOGIN_REQUIRED'
              }));
            },
            isAuthenticated: async () => {
              return window.nativeAuth.isAuthenticated();
            }
          };
        };
      }
    })();

    true; // Required for injection to work
  `;

  // Handle messages from WebView
  const handleWebViewMessage = async (event: any) => {
    const message = JSON.parse(event.nativeEvent.data);

    switch (message.type) {
      case 'GET_TOKEN':
        await handleTokenRequest(message);
        break;

      case 'SILENT_AUTH':
        await handleSilentAuth(message);
        break;

      case 'LOGIN_REQUIRED':
        await performNativeAuth();
        break;
    }
  };

  const handleTokenRequest = async (message: any) => {
    try {
      // Get token for requested audience
      let token = tokens.get(message.audience);

      if (!token || isTokenExpired(token)) {
        // Refresh token using native auth
        token = await refreshTokenForAudience(message.audience);
        setTokens(prev => new Map(prev).set(message.audience, token!));
      }

      // JSON.stringify, not quotes: any value carrying a quote would
      // otherwise close the string and run as code inside the WebView.
      webViewRef.current?.injectJavaScript(`
        window.handleTokenResponse(
          ${JSON.stringify(message.requestId)},
          ${JSON.stringify(token)},
          null
        );
        true;
      `);
    } catch (error) {
      // Send error back to WebView
      webViewRef.current?.injectJavaScript(`
        window.handleTokenResponse(
          ${JSON.stringify(message.requestId)},
          null,
          ${JSON.stringify((error as Error).message)}
        );
        true;
      `);
    }
  };

  const handleSilentAuth = async (message: any) => {
    try {
      // Check if we have valid session
      const storedTokens = await AsyncStorage.getItem('auth_tokens');

      if (storedTokens) {
        const tokens = JSON.parse(storedTokens);

        if (tokens.expiresAt > Date.now()) {
          // We have valid tokens, get token for requested audience
          const audienceToken = await getTokenForAudience(
            message.options.audience
          );

          webViewRef.current?.injectJavaScript(`
            window.handleSilentAuthResponse({
              accessToken: ${JSON.stringify(audienceToken)}
            }, null);
            true;
          `);
          return;
        }
      }

      // Try to refresh
      const refreshed = await refreshAuth();
      if (refreshed) {
        const audienceToken = await getTokenForAudience(
          message.options.audience
        );

        webViewRef.current?.injectJavaScript(`
          window.handleSilentAuthResponse({
            accessToken: ${JSON.stringify(audienceToken)}
          }, null);
          true;
        `);
      } else {
        throw new Error('Silent auth failed, login required');
      }
    } catch (error) {
      webViewRef.current?.injectJavaScript(`
        window.handleSilentAuthResponse(
          null,
          ${JSON.stringify((error as Error).message)}
        );
        true;
      `);
    }
  };

  const refreshAuth = async () => {
    try {
      const storedTokens = await AsyncStorage.getItem('auth_tokens');
      if (!storedTokens) return false;

      const { refreshToken } = JSON.parse(storedTokens);

      // Use react-native-app-auth to refresh
      const result = await refresh(auth0Config, {
        refreshToken: refreshToken
      });

      // Update stored tokens
      await AsyncStorage.setItem('auth_tokens', JSON.stringify({
        accessToken: result.accessToken,
        idToken: result.idToken,
        refreshToken: result.refreshToken || refreshToken,
        expiresAt: new Date(result.accessTokenExpirationDate).getTime()
      }));

      return true;
    } catch (error) {
      console.error('Token refresh failed:', error);
      return false;
    }
  };

  return (
    <WebView
      ref={webViewRef}
      source={{ uri: url }}
      injectedJavaScript={injectedJavaScript}
      onMessage={handleWebViewMessage}
      sharedCookiesEnabled={true} // Important for session sharing
      thirdPartyCookiesEnabled={true} // For Auth0 cookies
      domStorageEnabled={true} // For localStorage
    />
  );
}

The Silent Login Flow in React Native

Four fallbacks, tried in order, before the app gives up and shows a login screen:

// silent-login-flow.ts
class SilentLoginFlow {
  private auth0: Auth0Native;
  private tokenCache: TokenCache;
  private webViewBridge: WebViewBridge;

  async performSilentLogin(): Promise<boolean> {
    // Step 1: Check native token cache
    const cachedTokens = await this.tokenCache.getTokens();

    if (cachedTokens && !this.isExpired(cachedTokens)) {
      // We have valid tokens, setup WebView bridge
      await this.setupWebViewBridge(cachedTokens);
      return true;
    }

    // Step 2: Check if we have refresh token
    const refreshToken = await this.tokenCache.getRefreshToken();

    if (refreshToken) {
      try {
        // Attempt refresh
        const newTokens = await this.auth0.refreshTokens(refreshToken);
        await this.tokenCache.storeTokens(newTokens);
        await this.setupWebViewBridge(newTokens);
        return true;
      } catch (error) {
        console.log('Refresh failed, trying Auth0 session');
      }
    }

    // Step 3: Check Auth0 session (SSO)
    try {
      const ssoTokens = await this.checkAuth0Session();
      if (ssoTokens) {
        await this.tokenCache.storeTokens(ssoTokens);
        await this.setupWebViewBridge(ssoTokens);
        return true;
      }
    } catch (error) {
      console.log('No Auth0 session found');
    }

    // Step 4: Biometric authentication fallback
    if (await this.isBiometricAvailable()) {
      const bioTokens = await this.attemptBiometricAuth();
      if (bioTokens) {
        await this.setupWebViewBridge(bioTokens);
        return true;
      }
    }

    return false; // Silent login failed, need explicit login
  }

  private async checkAuth0Session(): Promise<TokenSet | null> {
    // Use custom tab / ASWebAuthenticationSession for SSO check
    const ssoCheckUrl = `https://${AUTH0_DOMAIN}/authorize?` +
      `client_id=${CLIENT_ID}&` +
      `response_type=token&` +
      `redirect_uri=${REDIRECT_URI}&` +
      `scope=openid profile email&` +
      `prompt=none&` + // Critical for silent auth
      `response_mode=query`;

    try {
      // This opens in a hidden web session
      const result = await InAppBrowser.openAuth(ssoCheckUrl, REDIRECT_URI, {
        ephemeralWebSession: false, // Use shared session
        preferEphemeralSession: false
      });

      if (result.type === 'success' && result.url) {
        const tokens = this.parseAuthResponse(result.url);
        return tokens;
      }

      return null;
    } catch (error) {
      return null;
    }
  }

  private async setupWebViewBridge(tokens: TokenSet) {
    // Inject tokens into WebView before loading
    const script = `
      window.__AUTH_TOKENS__ = {
        accessToken: ${JSON.stringify(tokens.accessToken)},
        expiresAt: ${tokens.expiresAt}
      };

      // Setup auto-renewal
      window.__AUTH_BRIDGE__ = {
        renewToken: async function(audience) {
          return new Promise((resolve) => {
            window.ReactNativeWebView.postMessage(JSON.stringify({
              type: 'RENEW_TOKEN',
              audience: audience
            }));
            window.__pendingRenewal = resolve;
          });
        }
      };
    `;

    this.webViewBridge.injectScript(script);
  }
}

Multi-Resource Refresh Tokens (MRRT)

Multi-Resource Refresh Tokens let one refresh token per application obtain access tokens for several APIs, which removes most of the bookkeeping above. Three constraints decide whether it applies: MRRT works with first-party applications only, each access token is still scoped to a single API, and the Management API cannot be part of an MRRT policy.

// Using MRRT for efficient multi-audience token refresh
class MRRTTokenManager {
  async refreshMultipleAudiences(refreshToken: string, audiences: string[]): Promise<Map<string, TokenSet>> {
    const tokens = new Map<string, TokenSet>();

    // MRRT allows one refresh token to get tokens for multiple audiences
    for (const audience of audiences) {
      try {
        const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            grant_type: 'refresh_token',
            client_id: CLIENT_ID,
            refresh_token: refreshToken,
            audience: audience
          })
        });

        const data = await response.json();
        tokens.set(audience, {
          accessToken: data.access_token,
          expiresAt: Date.now() + (data.expires_in * 1000),
          audience: audience
        });
      } catch (error) {
        console.error(`MRRT refresh failed for ${audience}:`, error);
      }
    }

    return tokens;
  }
}

Security Considerations

Critical Warning: Always validate JWT tokens on the backend. Never trust client-side token validation alone.

// Backend JWT validation with proper error handling
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

const client = jwksClient({
  jwksUri: `https://${AUTH0_DOMAIN}/.well-known/jwks.json`,
  cache: true,
  cacheMaxAge: 600000 // 10 minutes
});

function getKey(header: any, callback: Function) {
  client.getSigningKey(header.kid, (err: Error | null, key: any) => {
    if (err) {
      callback(err);
      return;
    }
    const signingKey = key.publicKey || key.rsaPublicKey;
    callback(null, signingKey);
  });
}

// Validate token with comprehensive error handling
const verifyToken = (token: string, audience: string): Promise<any> => {
  return new Promise((resolve, reject) => {
    jwt.verify(token, getKey, {
      audience: audience,
      issuer: `https://${AUTH0_DOMAIN}/`,
      algorithms: ['RS256']
    }, (err: Error | null, decoded: any) => {
      if (err) {
        console.error('JWT verification failed:', err.message);
        reject(err);
      } else {
        resolve(decoded);
      }
    });
  });
};

Essential Security Measures:

  1. Token Storage: Use secure, encrypted storage for tokens
  2. Origin Validation: Always validate message origins in postMessage handlers
  3. HTTPS Only: Never transmit tokens over unencrypted connections
  4. Token Rotation: Implement proper refresh token rotation
  5. Audience Validation: Verify audience claims match expected values

Implementation Gotchas

Auth0 uses cookies for session management. In React Native WebViews, third-party cookies are often blocked. Solution:

// Enable cookie sharing between WebViews
const cookieManager = require('@react-native-cookies/cookies');

// Share Auth0 cookies across WebViews
await cookieManager.setFromResponse(
  `https://${AUTH0_DOMAIN}`,
  'auth0_session=...; SameSite=None; Secure'
);

2. The Race Condition

Several micro frontends request the same audience at the same moment, and each one starts its own network call. Collapse them onto a single promise:

class TokenRequestQueue {
  private queue: Map<string, Promise<string>> = new Map();

  async getToken(audience: string): Promise<string> {
    // If already fetching, return existing promise
    const existing = this.queue.get(audience);
    if (existing) return existing;

    // Create new fetch promise
    const fetchPromise = this.fetchToken(audience);
    this.queue.set(audience, fetchPromise);

    try {
      const token = await fetchPromise;
      return token;
    } finally {
      // Clean up after resolution
      this.queue.delete(audience);
    }
  }
}

React Native Security Implementation

  1. Secure Token Storage: Never store tokens in plain text. Use encrypted storage:
import * as Keychain from 'react-native-keychain';

// Store tokens with biometric protection
const storeTokensSecurely = async (tokens: TokenSet) => {
  try {
    await Keychain.setInternetCredentials(
      'auth.myapp.com',
      'tokens',
      JSON.stringify(tokens),
      {
        accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
        accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
        service: 'myapp-auth' // Unique service identifier
      }
    );
  } catch (error) {
    console.error('Secure storage failed:', error);
    throw new Error('Failed to store authentication tokens securely');
  }
};
  1. WebView Security with Message Validation:
const validateWebViewMessage = (event: any): boolean => {
  // Whitelist allowed origins
  const allowedOrigins = [
    'https://billing.myapp.com',
    'https://dashboard.myapp.com',
    'https://analytics.myapp.com'
  ];

  if (!allowedOrigins.includes(event.origin)) {
    console.error('Invalid origin:', event.origin);
    return false;
  }

  // Validate message structure
  if (!event.data || typeof event.data !== 'object') {
    console.error('Invalid message structure');
    return false;
  }

  // Validate required fields
  const requiredFields = ['type', 'requestId'];
  for (const field of requiredFields) {
    if (!event.data[field]) {
      console.error(`Missing required field: ${field}`);
      return false;
    }
  }

  return true;
};

When the Shell-Broker Pattern Fits

One session in the shell, per-audience tokens brokered over an origin-checked channel, and a native bridge for WebViews: that holds as long as the micro frontends are first-party, they share a controlled parent frame or a registrable domain, and the API count stays low enough that warming every audience at login is cheap. Where the applications are first-party, MRRT removes most of the refresh bookkeeping on top of it.

Override it when one of those stops being true. Partner-owned frontends belong on their own Auth0 applications with their own consent screens. An estate with dozens of audiences should fetch tokens lazily per route instead of warming them all at login. And if a micro frontend can reach a backend-for-frontend of its own, an httpOnly cookie session against that BFF beats moving tokens through the browser at all.

References

Related posts