Orchestrating Federated Authentication Across Actix-web and Ktor for a Pinia-Powered Micro-frontend Architecture


The mandate was clear: decompose a monolithic front-end into micro-frontends to accelerate independent team delivery. This decomposition, however, surfaced a significant architectural challenge on the backend. Our existing services were a polyglot mix—a high-throughput market data service written in Rust with Actix-web for its raw performance, and a series of core business logic services built with Kotlin and Ktor, chosen for productivity and JVM ecosystem maturity. The immediate pain point became evident: how to implement a single, secure, and seamless authentication and session management layer across this entire distributed and heterogeneous landscape. Any solution requiring users to log in multiple times was a non-starter, and managing disparate session mechanisms between a Rust and a Kotlin service would inevitably lead to security vulnerabilities and maintenance overhead.

Our initial, and quickly discarded, concept involved each micro-frontend’s backing service handling its own authentication. This would create an operational nightmare of credential and session synchronization. The clear path forward was a centralized identity provider (IdP) coupled with a stateless, token-based authentication mechanism. We settled on JSON Web Tokens (JWT) for their self-contained nature and broad library support across ecosystems. The architecture crystallized into three components: a dedicated Ktor service for authentication, a Ktor service for user profiles, and the existing Actix-web service for market data. All protected resources would validate the same JWT, issued by the authentication service. On the client side, a shared Pinia store would become the single source of truth for the authentication state, propagating it across all loaded micro-frontends.

sequenceDiagram
    participant MFE_Shell as Micro-frontend Shell
    participant Pinia as Pinia Auth Store
    participant Auth_Svc as Ktor Auth Service
    participant Profile_Svc as Ktor Profile Service
    participant Data_Svc as Actix-web Data Service

    MFE_Shell->>Pinia: User triggers login action
    Pinia->>+Auth_Svc: POST /login (username, password)
    Auth_Svc-->>-Pinia: Returns signed JWT
    Pinia->>MFE_Shell: Stores token, updates state to authenticated
    MFE_Shell-->>Profile_Svc: GET /api/profile (Authorization: Bearer JWT)
    Profile_Svc->>Profile_Svc: Validate JWT signature & claims
    Profile_Svc-->>MFE_Shell: Returns user profile data
    MFE_Shell-->>Data_Svc: GET /api/market-data (Authorization: Bearer JWT)
    Data_Svc->>Data_Svc: Validate JWT signature & claims
    Data_Svc-->>MFE_Shell: Returns market data

This diagram outlines the flow. The critical implementation details lie in how the Ktor and Actix-web services, despite their different languages and frameworks, can trust the same token, and how the front-end can manage this token seamlessly across module boundaries.

Implementing the Ktor Authentication Service

The foundation of the entire system is the service that issues trusted tokens. We chose Ktor for this due to its excellent support for JWT and its conciseness. A real-world project would integrate with an external OIDC provider, but for this build, we will manage user validation and token issuance internally.

The key is a robust JWT configuration. While asymmetric keys (like RS256) are superior in a production microservice environment—allowing services to validate tokens using a public key without needing a shared secret—we’ll use HS256 here for simplicity. The pitfall here is that the symmetric secret must be securely managed and distributed to all services that need to validate tokens.

Here is the core logic for the Ktor-based authentication service.

build.gradle.kts dependencies:

dependencies {
    // Ktor core
    implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-cio-jvm:$ktor_version")
    // For JWT
    implementation("io.ktor:ktor-server-auth-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktor_version")
    // For content negotiation (JSON)
    implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version")
    implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktor_version")
    // Logging
    implementation("ch.qos.logback:logback-classic:$logback_version")
}

AuthService.kt - The Application Entry Point:

package com.example.auth

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.engine.*
import io.ktor.server.cio.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import java.util.*

@Serializable
data class LoginRequest(val username: String, val password: String)

@Serializable
data class AuthResponse(val token: String)

fun main() {
    embeddedServer(CIO, port = 8080, host = "0.0.0.0", module = Application::authModule).start(wait = true)
}

fun Application.authModule() {
    // In a real project, these should come from a secure configuration source.
    val jwtSecret = environment.config.property("jwt.secret").getString()
    val jwtIssuer = environment.config.property("jwt.issuer").getString()
    val jwtAudience = environment.config.property("jwt.audience").getString()
    val jwtRealm = environment.config.property("jwt.realm").getString()

    install(ContentNegotiation) {
        json()
    }

    // This config is for validating tokens, which this service doesn't need to do,
    // but the resource services will need an identical configuration.
    install(Authentication) {
        jwt("auth-jwt") {
            realm = jwtRealm
            verifier(
                JWT
                    .require(Algorithm.HMAC256(jwtSecret))
                    .withAudience(jwtAudience)
                    .withIssuer(jwtIssuer)
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("username").asString() != "") {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }
        }
    }

    routing {
        post("/login") {
            val loginRequest = call.receive<LoginRequest>()

            // A common mistake is to perform complex logic here.
            // This endpoint should only be responsible for credential validation and token issuance.
            // Dummy validation for this example.
            if (loginRequest.username == "testuser" && loginRequest.password == "password") {
                val token = JWT.create()
                    .withAudience(jwtAudience)
                    .withIssuer(jwtIssuer)
                    .withClaim("username", loginRequest.username)
                    .withClaim("userId", "12345") // Add other relevant, non-sensitive claims
                    .withExpiresAt(Date(System.currentTimeMillis() + 60 * 60 * 1000)) // 1 hour expiration
                    .sign(Algorithm.HMAC256(jwtSecret))
                call.respond(AuthResponse(token))
            } else {
                call.respond(io.ktor.http.HttpStatusCode.Unauthorized, "Invalid credentials")
            }
        }

        // Example protected route for a different service (like user profiles)
        authenticate("auth-jwt") {
            get("/profile") {
                val principal = call.principal<JWTPrincipal>()
                val username = principal!!.payload.getClaim("username").asString()
                val userId = principal.payload.getClaim("userId").asString()
                call.respondText("Hello, $username! Your user ID is $userId.")
            }
        }
    }
}

This Ktor application does one thing: it exposes a /login endpoint that, upon successful credential validation, creates and signs a JWT containing essential claims. The configuration values (secret, issuer, audience) are the contract that all other services in the ecosystem must adhere to for validation.

Securing the Actix-web Service with Shared JWT Middleware

Now for the more challenging part: teaching the high-performance Rust service to understand and validate the tokens issued by its Kotlin cousin. This requires implementing custom middleware in Actix-web. The middleware will inspect incoming requests for the Authorization header, parse the Bearer token, and perform cryptographic verification using the shared secret.

Cargo.toml dependencies:

[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
jsonwebtoken = "8"
chrono = "0.4"
futures-util = "0.3"

The core of the solution is a struct that implements the Transform and Service traits from actix-service. This is the standard pattern for creating middleware in Actix-web 4.x.

auth_middleware.rs:

use std::future::{ready, Ready};
use actix_web::{
    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
    Error, HttpMessage,
};
use futures_util::future::LocalBoxFuture;
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String, // subject (usually user id)
    pub username: String,
    pub exp: usize,
}

pub struct JwtAuth;

impl<S, B> Transform<S, ServiceRequest> for JwtAuth
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type InitError = ();
    type Transform = JwtAuthMiddleware<S>;
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ready(Ok(JwtAuthMiddleware { service }))
    }
}

pub struct JwtAuthMiddleware<S> {
    service: S,
}

impl<S, B> Service<ServiceRequest> for JwtAuthMiddleware<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;

    forward_ready!(service);

    fn call(&self, req: ServiceRequest) -> Self::Future {
        // A common pitfall is to clone data unnecessarily.
        // We only extract the header here and pass the request on.
        let auth_header = req.headers().get("Authorization").cloned();
        
        if let Some(auth_val) = auth_header {
            if let Ok(auth_str) = auth_val.to_str() {
                if auth_str.starts_with("Bearer ") {
                    let token = &auth_str["Bearer ".len()..];

                    // In a production app, this secret MUST come from a secure source.
                    // Hardcoding it is a major security risk.
                    let secret = "your-super-secret-key-that-matches-ktor";
                    let decoding_key = DecodingKey::from_secret(secret.as_ref());
                    let validation = Validation::new(Algorithm::HS256);

                    match decode::<Claims>(token, &decoding_key, &validation) {
                        Ok(token_data) => {
                            // Token is valid. A crucial step is to pass the claims
                            // to the downstream handler. We use request extensions for this.
                            req.extensions_mut().insert(token_data.claims);
                        },
                        Err(_) => {
                            // Token is invalid (expired, bad signature, etc.)
                            return Box::pin(async {
                                Err(actix_web::error::ErrorUnauthorized("Invalid token"))
                            });
                        }
                    }
                }
            }
        } else {
            // No token provided.
            return Box::pin(async {
                Err(actix_web::error::ErrorUnauthorized("Authentication token required"))
            });
        }
        
        let fut = self.service.call(req);

        Box::pin(async move {
            let res = fut.await?;
            Ok(res)
        })
    }
}

This middleware is unforgiving. If the Authorization: Bearer <token> header is missing, malformed, or contains an invalid token, it immediately returns a 401 Unauthorized response. If validation is successful, it attaches the decoded claims to the request using req.extensions_mut().insert(). This allows the actual route handlers to access user information in a type-safe way without re-validating the token.

main.rs - Integrating the middleware:

mod auth_middleware;

use actix_web::{web, App, HttpServer, Responder, HttpRequest};
use crate::auth_middleware::{JwtAuth, Claims};

async fn get_market_data(req: HttpRequest) -> impl Responder {
    // Access the claims inserted by the middleware.
    // A robust implementation would handle the case where the extension is missing,
    // although our middleware logic prevents that for this route.
    if let Some(claims) = req.extensions().get::<Claims>() {
        format!("Hello, {}! Here is your exclusive market data.", claims.username)
    } else {
        // This case should not be reached if middleware is correctly applied.
        "Unauthorized access to market data.".to_string()
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    println!("🚀 Starting Actix-web server at http://127.0.0.1:8081");

    HttpServer::new(|| {
        App::new()
            .service(
                web::scope("/api")
                    // The JwtAuth middleware is applied to every route within this scope.
                    .wrap(JwtAuth)
                    .route("/market-data", web::get().to(get_market_data))
            )
    })
    .bind(("127.0.0.1", 8081))?
    .run()
    .await
}

The elegance of this approach is that the route handler get_market_data is completely decoupled from the mechanics of authentication. It simply expects Claims to be present in the request extensions. The middleware enforces the security contract for the entire /api scope.

Unifying Frontend State with Pinia

With the backend services now speaking the same authentication language, the final piece is the client-side implementation. In a micro-frontend architecture, different parts of the UI are developed and deployed independently. Sharing state like user authentication status is a classic problem. Pinia is an excellent solution for this in the Vue ecosystem. We define a dedicated auth store that becomes the single source of truth.

This store is responsible for:

  1. Calling the /login endpoint.
  2. Storing the JWT securely (using localStorage for this example).
  3. Providing reactive state (isAuthenticated, user) to all components.
  4. Intercepting all outgoing API requests to attach the Authorization header.

stores/auth.js - The Pinia Store:

import { defineStore } from 'pinia';
import axios from 'axios'; // Or your preferred HTTP client

// This instance should be configured with interceptors.
const apiClient = axios.create({
  baseURL: 'http://localhost:8080', // Base URL for auth service
});

// A separate instance for the resource servers
const resourceClient = axios.create();

export const useAuthStore = defineStore('auth', {
  state: () => ({
    token: localStorage.getItem('token') || null,
    user: null, // User profile info
    status: localStorage.getItem('token') ? 'success' : 'idle',
  }),
  getters: {
    isAuthenticated: (state) => !!state.token,
    authStatus: (state) => state.status,
  },
  actions: {
    async login(credentials) {
      try {
        this.status = 'loading';
        const response = await apiClient.post('/login', credentials);
        const token = response.data.token;

        this.token = token;
        localStorage.setItem('token', token);
        
        // Setup the interceptor after a successful login
        this.setupInterceptors();
        
        this.status = 'success';
        return Promise.resolve(token);
      } catch (error) {
        this.status = 'error';
        this.token = null;
        localStorage.removeItem('token');
        return Promise.reject(error);
      }
    },
    logout() {
      this.token = null;
      this.user = null;
      this.status = 'idle';
      localStorage.removeItem('token');
      // Here you would also remove the interceptor if your library supports it,
      // or simply let it fail silently on subsequent requests without a token.
    },
    // This is the critical piece for cross-service communication
    setupInterceptors() {
      resourceClient.interceptors.request.use(config => {
        if (this.token) {
          config.headers.Authorization = `Bearer ${this.token}`;
        }
        return config;
      }, error => {
        return Promise.reject(error);
      });
    },
  },
});

// Initialize interceptors if a token exists on page load
// This is crucial for maintaining the session across page refreshes.
const authStore = useAuthStore();
if (authStore.isAuthenticated) {
    authStore.setupInterceptors();
}

With this store, any Vue component from any micro-frontend can import and use it.

Component in Micro-frontend A (MarketData.vue):

<template>
  <div>
    <h2>Market Data (from Actix-web)</h2>
    <div v-if="authStore.isAuthenticated">
      <p v-if="loading">Loading...</p>
      <p v-else-if="error">{{ error }}</p>
      <pre v-else>{{ data }}</pre>
    </div>
    <p v-else>Please log in to view market data.</p>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useAuthStore } from '@/stores/auth';
// Assuming resourceClient is exported from the auth store or a central API module
import { resourceClient } from '@/api'; 

const authStore = useAuthStore();
const data = ref(null);
const loading = ref(false);
const error = ref(null);

onMounted(async () => {
  if (authStore.isAuthenticated) {
    loading.value = true;
    try {
      // The interceptor automatically adds the token.
      const response = await resourceClient.get('http://localhost:8081/api/market-data');
      data.value = response.data;
    } catch (err) {
      error.value = 'Failed to fetch market data. Your session may have expired.';
      // A common mistake is not handling token expiration gracefully.
      // Here, we could trigger the logout action.
      // authStore.logout();
    } finally {
      loading.value = false;
    }
  }
});
</script>

The system is now fully integrated. The Ktor service acts as the source of truth for identity. The Actix-web service enforces access control based on this truth without needing to know any implementation details of the auth service, other than the shared secret and token structure. The Pinia store on the front end successfully abstracts away the token management, providing a clean, reactive interface for any component, regardless of which micro-frontend it belongs to.

The choice to use a localStorage-based token has significant security implications, primarily vulnerability to XSS attacks. In a production environment with higher security requirements, a backend-for-frontend (BFF) pattern using secure, HttpOnly cookies is a more robust solution. This trades the statelessness of the client for improved security, which is often a necessary compromise. Furthermore, this implementation lacks a token refresh mechanism, meaning the user will be abruptly logged out upon expiration. A production system would implement a refresh token flow to provide a smoother user experience. Finally, the symmetric HS256 key strategy creates tight coupling; an RS256-based approach where resource services only need a public key to verify tokens would further decouple the services and improve the overall security posture by centralizing the private key to only the authentication service.


  TOC