Federating an MLOps Control Plane with Python WebAuthn and Turbopack-based Micro-frontends


Our MLOps platform had become a victim of its own success. What started as a simple Flask dashboard for model deployments had ballooned into a monolithic React application. The frontend codebase was a tangled mess where the data science, model deployment, and infrastructure teams constantly conflicted. Every minor UI change required a full application rebuild and deploy, creating a bottleneck that slowed everyone down. Compounding this, our reliance on password-based authentication was a persistent security audit failure, especially given the sensitive nature of the models and data being managed. A complete rethink was necessary.

The core idea was to decentralize ownership. We decided to break the monolithic UI into a federation of micro-frontends (MFEs), allowing each team to own their vertical slice of the platform—data labeling, experiment tracking, deployment monitoring, etc. For security, we made a non-negotiable decision to move to passwordless authentication using WebAuthn. The existing Python backend was to be preserved, but the entire frontend architecture and authentication flow needed to be rebuilt from the ground up.

Our technology selection process was guided by pragmatism and a calculated bet on performance.

  1. Micro-frontend Strategy: We chose Module Federation. It’s a mature concept, well-supported by Webpack, and allows for true runtime integration of separately deployed applications. This was critical for team autonomy.
  2. Frontend Build Tool: Here, we took a risk. The multi-MFE setup made our existing Webpack build times excruciatingly slow. We decided to adopt Turbopack. Its promise of radical performance improvements was too compelling to ignore, even given its relative immaturity compared to Webpack. We knew we would be pioneers here, and that meant accepting the risk of hitting edge cases.
  3. Backend & Authentication: We stuck with Python and FastAPI for its performance and ecosystem. For WebAuthn, we selected the webauthn library, which provides a solid server-side implementation of the Relying Party logic. This allowed us to build the required credential registration and verification endpoints without reinventing the low-level cryptographic flows. A common mistake here is trying to implement the WebAuthn spec from scratch, which is a recipe for security vulnerabilities.

The first and most critical piece of the puzzle was building a robust, secure authentication backend. The WebAuthn flow requires four distinct server endpoints to handle the registration and authentication ceremonies. Using FastAPI and Pydantic, we defined these endpoints with clear data contracts.

Here’s the core of our authentication service. It’s a self-contained FastAPI application that handles the WebAuthn logic. In a real-world project, the user_db and credential_db would be backed by a proper database like PostgreSQL, likely accessed via SQLAlchemy. For this demonstration, in-memory dictionaries suffice to illustrate the logic.

# auth_service.py
import os
import logging
from typing import Dict, List, Optional
from uuid import uuid4

from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field

from webauthn import (
    generate_registration_options,
    verify_registration_response,
    generate_authentication_options,
    verify_authentication_response,
)
from webauthn.helpers.structs import (
    RegistrationCredential,
    AuthenticationCredential,
    PublicKeyCredentialCreationOptions,
    PublicKeyCredentialRequestOptions,
)

# --- Basic Setup & Logging ---
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# --- Configuration ---
# In a production environment, these must be securely managed.
RP_ID = "localhost"  # The domain of your app
RP_NAME = "MLOps Federated Platform"
EXPECTED_ORIGIN = "http://localhost:3000"

# --- In-Memory "Database" for Demonstration ---
# In production, use a persistent database (e.g., PostgreSQL with SQLAlchemy).
class User(BaseModel):
    id: str
    username: str
    credentials: List[RegistrationCredential] = []

user_db: Dict[str, User] = {}
credential_db: Dict[str, RegistrationCredential] = {}

# --- FastAPI App Initialization ---
app = FastAPI(title="WebAuthn MLOps Auth Service")

# A practical concern in micro-frontend architectures is CORS.
# The shell and MFEs will run on different ports during development.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "http://localhost:3001", "http://localhost:3002"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# --- Pydantic Models for API Contracts ---
class UsernamePayload(BaseModel):
    username: str

class RegistrationVerificationPayload(BaseModel):
    username: str
    credential: RegistrationCredential

class AuthenticationVerificationPayload(BaseModel):
    username: str
    credential: AuthenticationCredential

# --- WebAuthn Endpoints ---
@app.post("/generate-registration-options", response_model=PublicKeyCredentialCreationOptions)
async def start_registration(payload: UsernamePayload):
    username = payload.username
    if username in user_db:
        raise HTTPException(status_code=400, detail="Username already exists")
    
    user_id = str(uuid4())
    new_user = User(id=user_id, username=username)
    user_db[username] = new_user

    logger.info(f"Generating registration options for user: {username} (ID: {user_id})")

    # The pitfall here is not excluding existing credentials for the user.
    # While this user is new, for a scenario where a user adds a new device,
    # this prevents re-registering an already-registered authenticator.
    options = generate_registration_options(
        rp_id=RP_ID,
        rp_name=RP_NAME,
        user_id=new_user.id.encode('utf-8'),
        user_name=new_user.username,
        exclude_credentials=[
            {"type": "public-key", "id": cred.id} for cred in new_user.credentials
        ]
    )
    # Temporary storage of challenge; in production, use a short-lived store like Redis.
    return options

@app.post("/verify-registration")
async def process_registration(payload: RegistrationVerificationPayload):
    user = user_db.get(payload.username)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    try:
        # The core verification logic. This function handles all the cryptographic checks.
        verified_credential = verify_registration_response(
            credential=payload.credential,
            expected_challenge=b'', # In a real app, retrieve the challenge from Redis/session
            expected_origin=EXPECTED_ORIGIN,
            expected_rp_id=RP_ID,
            require_user_verification=False # Set to True for biometrics
        )
        
        logger.info(f"Successfully verified registration for {payload.username}")

        # Persist the new credential.
        user.credentials.append(verified_credential)
        credential_db[verified_credential.id] = verified_credential

        return {"status": "ok", "verified": True}
    except Exception as e:
        logger.error(f"Registration verification failed for {payload.username}: {e}")
        raise HTTPException(status_code=400, detail=f"Registration failed: {e}")

@app.post("/generate-authentication-options", response_model=PublicKeyCredentialRequestOptions)
async def start_authentication(payload: UsernamePayload):
    user = user_db.get(payload.username)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    if not user.credentials:
        raise HTTPException(status_code=400, detail="No credentials registered for this user")

    logger.info(f"Generating authentication options for user: {payload.username}")
    
    options = generate_authentication_options(
        rp_id=RP_ID,
        allow_credentials=[
            {"type": "public-key", "id": cred.id} for cred in user.credentials
        ]
    )
    # Again, the challenge should be stored temporarily.
    return options

@app.post("/verify-authentication")
async def process_authentication(payload: AuthenticationVerificationPayload):
    user = user_db.get(payload.username)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    # Find the specific credential being used for this sign-in attempt.
    cred_id_bytes = payload.credential.id
    registered_credential = credential_db.get(cred_id_bytes)
    if not registered_credential:
        raise HTTPException(status_code=404, detail="Credential not found")
        
    try:
        # Here, we verify the signature provided by the authenticator.
        # The sign_count check is crucial for preventing replay attacks.
        verify_authentication_response(
            credential=payload.credential,
            expected_challenge=b'', # Retrieve from Redis/session
            expected_origin=EXPECTED_ORIGIN,
            expected_rp_id=RP_ID,
            credential_public_key=registered_credential.public_key,
            credential_current_sign_count=registered_credential.sign_count,
            require_user_verification=False,
        )
        
        # IMPORTANT: Update the sign count in the database to prevent replay attacks.
        registered_credential.sign_count = payload.credential.response.authenticator_data.sign_count
        credential_db[cred_id_bytes] = registered_credential
        
        logger.info(f"Successfully verified authentication for {payload.username}")
        # In a real app, you would now generate a session token (e.g., a JWT).
        return {"status": "ok", "verified": True}
    except Exception as e:
        logger.error(f"Authentication verification failed for {payload.username}: {e}")
        raise HTTPException(status_code=400, detail=f"Authentication failed: {e}")

To test this service, you can run it with uvicorn auth_service:app --reload. The key takeaway is the strict separation of concerns: this service does one thing—WebAuthn ceremonies—and does it well. A common mistake is to embed this logic deep within a larger business-logic application, making it hard to test and maintain.

With the backend in place, we turned to the frontend. We needed a “shell” or “host” application. This Next.js app would be responsible for the overall page layout, routing, and, critically, orchestrating the authentication flow and loading the MFEs. This is where we integrated Turbopack and Module Federation.

The configuration was not straightforward. Turbopack’s Module Federation support is less documented than Webpack’s, and we had to experiment. Here is the final, working next.config.js for our shell application:

// shell-app/next.config.js

const { NextFederationPlugin } = require('@module-federation/nextjs-mf');

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Turbopack MUST be enabled.
  experimental: {
    turbo: true,
  },
  webpack(config, options) {
    const { isServer } = options;

    // A critical configuration for Module Federation.
    // We define the MFEs that this shell can consume.
    config.plugins.push(
      new NextFederationPlugin({
        name: 'shell',
        filename: 'static/chunks/remoteEntry.js',
        remotes: {
          // The key 'model_registry' is how we will import it in our code.
          // The value 'model_registry@http://localhost:3001/_next/static/chunks/remoteEntry.js'
          // points to the remote MFE's entry file.
          'model_registry': `model_registry@http://localhost:3001/_next/static/${isServer ? 'ssr' : 'chunks'}/remoteEntry.js`,
          'deployment_monitor': `deployment_monitor@http://localhost:3002/_next/static/${isServer ? 'ssr' : 'chunks'}/remoteEntry.js`,
        },
        exposes: {
          // The shell could also expose components, e.g., a shared header.
          './AuthContext': './src/contexts/AuthContext.js',
        },
        shared: {
          // Define shared dependencies to avoid loading them multiple times.
          // A pitfall is not carefully managing these versions, which can lead
          // to subtle runtime bugs.
          'react': { singleton: true, requiredVersion: false },
          'react-dom': { singleton: true, requiredVersion: false },
        },
      })
    );
    return config;
  },
};

module.exports = nextConfig;

The shell application’s main job is to provide the context for authentication and dynamically render the MFEs. We used a simple React Context to hold the user’s authentication state.

// shell-app/src/app/page.js
'use client';

import React, { useState, Suspense } from 'react';

// Dynamically import MFEs. This is the core of runtime integration.
// The import path 'model_registry/RegistryPage' corresponds to the remote name
// and the exposed component name.
const ModelRegistryPage = React.lazy(() => import('model_registry/RegistryPage'));
const DeploymentMonitorPage = React.lazy(() => import('deployment_monitor/MonitorPage'));

// A simplified auth component to handle WebAuthn flow
import AuthComponent from '../components/Auth'; 

export default function Home() {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [username, setUsername] = useState('');
  const [activeMFE, setActiveMFE] = useState(null);

  if (!isAuthenticated) {
    return <AuthComponent onLoginSuccess={(user) => {
      setIsAuthenticated(true);
      setUsername(user);
    }} />;
  }

  return (
    <div>
      <nav style={{ padding: '1rem', background: '#eee', display: 'flex', gap: '1rem' }}>
        <span>MLOps Platform Shell</span>
        <button onClick={() => setActiveMFE('registry')}>Model Registry</button>
        <button onClick={() => setActiveMFE('monitor')}>Deployment Monitor</button>
        <span style={{ marginLeft: 'auto' }}>User: {username}</span>
      </nav>
      <main style={{ padding: '1rem' }}>
        <Suspense fallback={<div>Loading MFE...</div>}>
          {activeMFE === 'registry' && <ModelRegistryPage user={username} />}
          {activeMFE === 'monitor' && <DeploymentMonitorPage user={username} />}
          {activeMFE === null && <h2>Select a tool from the navigation bar.</h2>}
        </Suspense>
      </main>
    </div>
  );
}

Next, we created a sample MFE for the “Model Registry” team. It’s another self-contained Next.js application, also using Turbopack. Its configuration exposes a component for the shell to consume.

// model-registry-mfe/next.config.js

const { NextFederationPlugin } = require('@module-federation/nextjs-mf');

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    turbo: true,
  },
  webpack(config, options) {
    const { isServer } = options;
    config.plugins.push(
      new NextFederationPlugin({
        name: 'model_registry', // Must match the name in the shell's remote config
        filename: 'static/chunks/remoteEntry.js',
        exposes: {
          // We expose our main page component under the alias 'RegistryPage'.
          './RegistryPage': './src/components/RegistryPage.js',
        },
        shared: {
          'react': { singleton: true, requiredVersion: false },
          'react-dom': { singleton: true, requiredVersion: false },
        },
      })
    );
    return config;
  },
};

module.exports = nextConfig;

The component itself is just a standard React component. The key is that it’s developed and deployed entirely independently of the shell.

// model-registry-mfe/src/components/RegistryPage.js
'use client';

import React from 'react';

// This component is developed in isolation by the model registry team.
// It receives any necessary context (like the authenticated user) as props from the shell.
const RegistryPage = ({ user }) => {
  return (
    <div style={{ border: '1px solid blue', padding: '1rem' }}>
      <h2>Model Registry Micro-frontend</h2>
      <p>This component is served independently from the shell application.</p>
      <p>Authenticated User: <strong>{user}</strong></p>
      <ul>
        <li>Model: `bert-base-uncased` - Version: 1.2.0</li>
        <li>Model: `resnet50` - Version: 2.5.1</li>
      </ul>
    </div>
  );
};

export default RegistryPage;

This architecture is powerful. The shell handles cross-cutting concerns like authentication and navigation, while feature teams build their MFEs in complete isolation. We use a simple prop-drilling mechanism to pass the authenticated user’s identity to the MFE. For more complex state sharing, a shared event bus or a lightweight state management library exposed by the shell would be the next logical step.

A crucial part of this architecture is the overall system flow.

sequenceDiagram
    participant Browser
    participant ShellApp (Port 3000)
    participant AuthService (Python)
    participant ModelRegistryMFE (Port 3001)

    Browser->>ShellApp: GET /
    ShellApp-->>Browser: Load shell, show AuthComponent
    Browser->>AuthService: POST /generate-registration-options (user: 'ml_engineer')
    AuthService-->>Browser: Return registration options (challenge)
    Browser->>Browser: User interacts with authenticator (e.g., Touch ID)
    Browser->>AuthService: POST /verify-registration (with signed response)
    AuthService-->>Browser: Return {verified: true}
    
    Browser->>ShellApp: User is now authenticated
    ShellApp-->>Browser: Render main navigation
    
    Browser->>ShellApp: User clicks 'Model Registry'
    ShellApp->>ModelRegistryMFE: Dynamically fetch remoteEntry.js
    ModelRegistryMFE-->>ShellApp: Provide RegistryPage component
    ShellApp->>ShellApp: Render RegistryPage, passing user prop
    ShellApp-->>Browser: Display the fully composed page

The final result is a federated MLOps platform. Teams can deploy their frontends independently, and the user experience is seamless. The build process, powered by Turbopack, is orders of magnitude faster than our old Webpack setup, which is a massive boost to developer productivity. The WebAuthn integration provides phishing-resistant, passwordless security, satisfying our compliance requirements.

However, this solution is not without its own set of challenges and limitations. Our reliance on Turbopack’s experimental Module Federation feature means we are exposed to potential breaking changes in future Next.js or Turbopack releases; we must maintain a thorough suite of end-to-end tests to catch regressions early. The current method of passing state via props is sufficient for now but will not scale to more complex interactions between MFEs; a more robust cross-MFE communication strategy will be necessary. Furthermore, we’ve only addressed authentication—the “who you are.” The equally important problem of authorization—the “what you are allowed to do”—still needs to be solved, likely by propagating JWTs with role-based claims from the auth service and enforcing access control both at the API gateway and within each micro-frontend’s backend. This architecture is a solid foundation, but the work of building a truly enterprise-grade platform is an ongoing process of refinement.


  TOC