Handling security is one of the most critical and challenging aspects of a micro frontend architecture. The core principle of micro frontends is isolation, but authentication (AuthN – who you are) and authorization (AuthZ – what you’re allowed to do) are inherently cross-cutting concerns that must be managed centrally.
The most robust, secure, and widely-adopted pattern is Centralized Authentication in the Shell Application. In this model, the host (or shell) application is solely responsible for the entire authentication lifecycle. The micro frontends are “dumb” in this regard; they do not manage tokens or login flows but simply consume the authentication state provided by the shell.
Core Principles of the Centralized Model
- Single Point of Login: The user always authenticates through the shell application. The login form, redirection to an IdP (Identity Provider like Auth0, Okta, or Azure AD), and token reception are all handled exclusively by the shell.
- Shell as Token Custodian: The shell securely stores the authentication token (e.g., a JSON Web Token – JWT). It is responsible for storing it (e.g., in a secure, HttpOnly cookie or browser storage) and managing its lifecycle (e.g., refresh tokens). The raw token should never be passed directly to the micro frontends.
- Authentication State, Not Tokens: The shell shares the state of authentication (e.g., “user is logged in,” “user’s name is Alice,” “user has ‘admin’ role”) with the micro frontends, not the token itself.
- Centralized API Interception: The shell intercepts all outgoing HTTP requests made from any micro frontend. If a request is destined for a protected API, the shell attaches the authentication token before sending it. This is the most crucial piece of the puzzle.
- Centralized Route Protection: The shell is responsible for guarding routes, including the routes that load entire micro frontends. It can prevent a user from even loading a micro frontend if they are not authenticated or authorized.
Practical Example: Implementing Centralized Auth
Let’s build a practical solution with a shell app and a dashboard-mfe.
Step 1: Create the Central AuthService in the Shell
This service will manage everything related to authentication.
// shell/src/app/auth/auth.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
export interface UserProfile { name: string; roles: string[];
}
@Injectable({ providedIn: 'root',
})
export class AuthService { // Use BehaviorSubject to hold and broadcast the current auth state private isAuthenticatedSubject = new BehaviorSubject<boolean>(false); public isAuthenticated$ = this.isAuthenticatedSubject.asObservable(); private userProfileSubject = new BehaviorSubject<UserProfile | null>(null); public userProfile$ = this.userProfileSubject.asObservable(); constructor() { // In a real app, you'd check for an existing token here } // Simulate a login login(username: string, password: string): Observable<boolean> { // In a real app, this would be an HttpClient call to your auth server return of(true).pipe( tap(() => { const token = 'fake-jwt-token-from-server'; localStorage.setItem('authToken', token); // Use secure storage in a real app! const user: UserProfile = { name: 'Alice', roles: ['admin', 'editor'], }; this.isAuthenticatedSubject.next(true); this.userProfileSubject.next(user); }) ); } logout(): void { localStorage.removeItem('authToken'); this.isAuthenticatedSubject.next(false); this.userProfileSubject.next(null); } getToken(): string | null { return localStorage.getItem('authToken'); }
}
Step 2: Create a Central HttpInterceptor in the Shell
This is the magic component that attaches the token to outgoing requests from anywhere in the application, including the loaded micro frontends.
// shell/src/app/auth/auth.interceptor.ts
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor { constructor(private authService: AuthService) {} intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { const token = this.authService.getToken(); const isApiUrl = request.url.startsWith('/api/'); // Only attach token to your API calls if (token && isApiUrl) { request = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); } return next.handle(request); }
}
Step 3: Create a Central AuthGuard in the Shell
This guard will protect the route that loads our micro frontend.
// shell/src/app/auth/auth.guard.ts
import { Injectable } from '@angular/core';
import { CanLoad, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root',
})
export class AuthGuard implements CanLoad { constructor(private authService: AuthService, private router: Router) {} canLoad(): Observable<boolean> { return this.authService.isAuthenticated$.pipe( take(1), map(isAuthenticated => { if (!isAuthenticated) { this.router.navigate(['/login']); return false; } return true; }) ); }
}
Step 4: Wire Everything Together in the Shell
Update the shell’s app.module.ts and app-routing.module.ts.
// shell/src/app/app.module.ts
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { AuthInterceptor } from './auth/auth.interceptor';
// ...other imports
@NgModule({ imports: [ BrowserModule, HttpClientModule, // Ensure HttpClientModule is imported // ... ], providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, ], bootstrap: [AppComponent],
})
export class AppModule {}
// shell/src/app/app-routing.module.ts
import { Routes } from '@angular/router';
import { AuthGuard } from './auth/auth.guard';
import { LoginComponent } from './login/login.component'; // A simple login component
export const routes: Routes = [ { path: 'login', component: LoginComponent }, { path: 'dashboard', canLoad: [AuthGuard], // Protect this route loadChildren: () => import('dashboard-mfe/Module').then((m) => m.DashboardModule), }, // ... other routes
];
Step 5: Sharing the Auth State with the Micro Frontend
The micro frontend doesn’t need the login() method or the token. It just needs the isAuthenticated$ and userProfile$ observables. We’ll use a public, shared API for this.
1. Define a Shared Service Type
// shell/src/app/auth/shared-auth.service.ts
import { Observable } from 'rxjs';
import { UserProfile } from './auth.service';
export abstract class SharedAuthService { abstract isAuthenticated$: Observable<boolean>; abstract userProfile$: Observable<UserProfile | null>;
}
2. Update AuthService to Implement It
// shell/src/app/auth/auth.service.ts
import { SharedAuthService } from './shared-auth.service';
// ...
@Injectable({ providedIn: 'root' })
export class AuthService extends SharedAuthService { // ... same content as before
}
3. Configure Webpack in the Shell
Use @angular-architects/module-federation’s share helper to share the running instance of the AuthService.
// shell/webpack.config.js
const { share, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({ // ... remotes config shared: share({ // ... share Angular dependencies // Share the AuthService instance from the shell's injector '@app/auth': { singleton: true, strictVersion: true, requiredVersion: 'auto', // The key part: this tells remotes to get the instance // from the shell's DI container instead of creating a new one. provider: './src/app/auth/auth.service.ts', }, }),
});
Step 6: Consume the Shared State in the Micro Frontend
Now, the dashboard-mfe can use this shared service to tailor its UI and make API calls.
// dashboard-mfe/src/app/dashboard/dashboard.component.ts
import { Component } from ‘@angular/core’;
import { HttpClient } from ‘@angular/common/http’;
import { Observable } from ‘rxjs’;
// This import works because of the webpack config. It imports the abstract class for type safety.
import { SharedAuthService } from ‘@app/auth’;
@Component({
selector: ‘app-dashboard’,
template: `
<h2>Welcome to the Dashboard MFE</h2>
<ng-container *ngIf=”authService.isAuthenticated$ | async”>
<p *ngIf=”authService.userProfile$ | async as user”>
Hello, {{ user.name }}!
<!– Fine-grained authorization inside the MFE –>
<button *ngIf=”user.roles.includes(‘admin’)” (click)=”loadAdminData()”>
Load Admin Data
</button>
</p>
<pre>{{ data$ | async | json }}</pre>
</ng-container>
`,
})
export class DashboardComponent {
data$: Observable<any> | undefined;
// Inject the shared service using the abstract class as the token
constructor(public authService: SharedAuthService, private http: HttpClient) {}
loadAdminData() {
// When this call is made, the shell’s interceptor will AUTOMATICALLY
// attach the ‘Authorization: Bearer …’ header. The MFE is completely
// unaware of the token.
this.data$ = this.http.get(‘/api/admin/data’);
}
}
Authorization: Fine-Grained Control
The pattern extends beautifully to authorization:
- Coarse-Grained (Route-Level): The shell’s AuthGuard handles this. You can create a RoleGuard in the shell that checks authService.userProfile$.pipe(map(u => u.roles.includes(‘admin’))) to protect entire MFE routes.
- Fine-Grained (UI-Level): As shown in the dashboard.component.ts example, the MFE consumes the shared userProfile$ observable and uses the roles array to conditionally show or hide UI elements (like an “Admin” button) using *ngIf. This allows the MFE to manage its own internal authorization rules based on the identity provided by the shell.
Final Words
Centralized authentication in the shell app is the safest and cleanest way to handle security in micro frontends. It keeps tokens out of individual MFEs and simplifies state sharing. To get this right, hire Angular developers who know how to architect secure, scalable systems from day one.