Microsoft Authentication (MSAL) in Capacitor Angular Apps: A Complete Guide
Published: February 16, 2026
Author: Václav Švára
Generated with assistance from: Claude (Anthropic AI)
Table of Contents
- Introduction
- The Challenge
- Architecture Overview
- Prerequisites
- Azure AD App Registration
- MSAL Configuration
- Authentication Service Implementation
- Deep Link Handling (iOS)
- Deep Link Handling (Android)
- Login Flow
- Token Management
- HTTP Interceptor
- Testing the Implementation
- Troubleshooting
- Security Best Practices
- Conclusion
Introduction
Implementing Microsoft Authentication (MSAL) in a Capacitor Angular application presents unique challenges compared to standard web applications. The combination of native mobile platforms (iOS/Android) with web technologies requires special handling of authentication flows, deep links, and token management.
This comprehensive guide walks you through every step of implementing MSAL authentication in a Capacitor Angular app, from Azure AD configuration to production-ready code.
The Challenge
Why is MSAL different in Capacitor?
- Custom URL Schemes: Capacitor apps run on
capacitor://localhostinstead ofhttps://domains - Deep Link Redirects: OAuth redirects must be handled by native app deep links
- Browser Context: MSAL expects a standard browser environment, but Capacitor uses a WebView
- Token Storage: Secure token storage differs between web and native platforms
- Silent Token Refresh: Silent refresh in iframes doesn't work in native WebViews
What we'll build
A complete authentication solution that:
- ✅ Works on iOS, Android, and Web (PWA)
- ✅ Handles OAuth2 redirect flow properly
- ✅ Stores tokens securely
- ✅ Automatically refreshes expired tokens
- ✅ Attaches JWT tokens to API requests
- ✅ Handles login/logout gracefully
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Angular Application │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Authentication Service │ │
│ │ - Login / Logout │ │
│ │ - Token acquisition │ │
│ │ - Silent refresh │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ MSAL Angular Library │ │
│ │ (@azure/msal-angular, @azure/msal-browser) │ │
│ └──────────────────┬───────────────────────────────────┘ │
└────────────────────┼────────────────────────────────────────┘
│
┌───────────┴───────────┐
│ │
┌────▼─────┐ ┌─────▼────┐
│ Web │ │ Native │
│ (PWA) │ │ (iOS/ │
│ │ │ Android) │
│ Standard │ │ Deep │
│ Redirect │ │ Links │
└────┬─────┘ └─────┬────┘
│ │
└──────────┬───────────┘
│
┌──────────▼──────────┐
│ Azure AD / Entra │
│ OAuth2 Provider │
└─────────────────────┘
Prerequisites
Required Knowledge
- Angular (v16+)
- TypeScript
- Capacitor basics
- OAuth2 / OpenID Connect concepts
Required Tools
# Node.js and npm
node --version # v18+ recommended
npm --version
# Angular CLI
npm install -g @angular/cli
# Capacitor CLI
npm install -g @capacitor/cli
# Xcode (macOS, for iOS)
# Android Studio (for Android)
Required Packages
# MSAL packages
npm install @azure/msal-browser @azure/msal-angular
# Capacitor core
npm install @capacitor/core @capacitor/cli
# Capacitor platforms
npm install @capacitor/ios @capacitor/android
# RxJS (usually already in Angular)
npm install rxjs
Azure AD App Registration
Step 1: Create App Registration
- Go to Azure Portal
- Navigate to Azure Active Directory → App registrations
- Click "New registration"
Configuration:
Name: My Mobile App
Supported account types: Accounts in any organizational directory and personal Microsoft accounts
Redirect URI: Leave empty for now (we'll add later)
- Click "Register"
Step 2: Note Application (client) ID
After registration, you'll see:
Application (client) ID: 12345678-1234-1234-1234-123456789abc
Directory (tenant) ID: common (for multi-tenant)
Save these values - you'll need them for configuration.
Step 3: Add Redirect URIs
Go to Authentication → Add a platform
For Web (PWA):
Platform: Single-page application (SPA)
Redirect URI: http://localhost:4200
https://yourapp.com
For iOS:
Platform: iOS / macOS
Redirect URI: msauth.com.yourcompany.yourapp://auth
For Android:
Platform: Android
Redirect URI: msauth://com.yourcompany.yourapp/signature-hash
Platform-agnostic (Capacitor):
Platform: Single-page application (SPA)
Redirect URI: capacitor://localhost
Step 4: Configure Token Settings
Authentication → Implicit grant and hybrid flows:
- ✅ Access tokens (used for implicit flows)
- ✅ ID tokens (used for implicit and hybrid flows)
Authentication → Advanced settings:
- Allow public client flows: Yes
Step 5: Expose API (Optional, for custom scopes)
If your app has a backend API:
- Expose an API → Add a scope
Scope name: api_access
Display name: Access API
Description: Allows the app to access the backend API
State: Enabled
- Note the scope URI:
api://12345678-1234-1234-1234-123456789abc/api_access
MSAL Configuration
Environment Configuration
Create environment files with MSAL config:
src/environments/environment.ts (Development)
export const environment = {
production: false,
auth: {
clientId: '12345678-1234-1234-1234-123456789abc',
authority: 'https://login.microsoftonline.com/common',
redirectUri: 'http://localhost:4200',
postLogoutRedirectUri: 'http://localhost:4200/login',
scopes: [
'user.read',
'api://12345678-1234-1234-1234-123456789abc/api_access'
]
},
api: {
baseUrl: 'http://localhost:5000/api'
}
};
src/environments/environment.capacitor.ts (Capacitor/Native)
export const environment = {
production: true,
auth: {
clientId: '12345678-1234-1234-1234-123456789abc',
authority: 'https://login.microsoftonline.com/common',
redirectUri: 'msauth.com.yourcompany.yourapp://auth', // iOS
// redirectUri: 'msauth://com.yourcompany.yourapp/signature', // Android
postLogoutRedirectUri: 'msauth.com.yourcompany.yourapp://auth',
scopes: [
'user.read',
'api://12345678-1234-1234-1234-123456789abc/api_access'
]
},
api: {
baseUrl: 'https://api.yourapp.com/api'
}
};
MSAL Configuration Factory
Create a factory function for MSAL configuration:
src/app/auth/msal-config.factory.ts
import { InjectionToken } from '@angular/core';
import {
IPublicClientApplication,
PublicClientApplication,
BrowserCacheLocation,
LogLevel,
Configuration
} from '@azure/msal-browser';
import { Capacitor } from '@capacitor/core';
import { environment } from '../../environments/environment';
export const MSAL_INSTANCE = new InjectionToken<IPublicClientApplication>('MSAL_INSTANCE');
export function MSALInstanceFactory(): IPublicClientApplication {
const isNative = Capacitor.isNativePlatform();
const msalConfig: Configuration = {
auth: {
clientId: environment.auth.clientId,
authority: environment.auth.authority,
redirectUri: environment.auth.redirectUri,
postLogoutRedirectUri: environment.auth.postLogoutRedirectUri,
navigateToLoginRequestUrl: false // Important for Capacitor!
},
cache: {
cacheLocation: isNative ? BrowserCacheLocation.LocalStorage : BrowserCacheLocation.SessionStorage,
storeAuthStateInCookie: false
},
system: {
loggerOptions: {
loggerCallback: (level: LogLevel, message: string, containsPii: boolean) => {
if (containsPii) return;
switch (level) {
case LogLevel.Error:
console.error('[MSAL]', message);
break;
case LogLevel.Warning:
console.warn('[MSAL]', message);
break;
case LogLevel.Info:
console.info('[MSAL]', message);
break;
case LogLevel.Verbose:
console.debug('[MSAL]', message);
break;
}
},
logLevel: LogLevel.Verbose
},
allowNativeBroker: false, // Important for mobile
windowHashTimeout: 60000,
iframeHashTimeout: 6000,
loadFrameTimeout: 0
}
};
return new PublicClientApplication(msalConfig);
}
Key Configuration Points:
- navigateToLoginRequestUrl: false - Prevents MSAL from navigating after login (we handle it manually)
- cacheLocation - LocalStorage for native (persistent), SessionStorage for web
- allowNativeBroker: false - Disables native broker (Authenticator app) which doesn't work in WebView
- windowHashTimeout/loadFrameTimeout - Adjusted for mobile performance
Authentication Service Implementation
Create a comprehensive authentication service:
src/app/auth/auth.service.ts
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
IPublicClientApplication,
AuthenticationResult,
AccountInfo,
InteractionRequiredAuthError,
SilentRequest,
PopupRequest,
EndSessionRequest
} from '@azure/msal-browser';
import { BehaviorSubject, Observable, from, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { Capacitor } from '@capacitor/core';
import { Browser } from '@capacitor/browser';
import { MSAL_INSTANCE } from './msal-config.factory';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private msalInstance = inject(MSAL_INSTANCE);
private router = inject(Router);
private isNative = Capacitor.isNativePlatform();
// Observable state
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
private currentUserSubject = new BehaviorSubject<AccountInfo | null>(null);
public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
public currentUser$ = this.currentUserSubject.asObservable();
constructor() {
this.initializeAuth();
}
/**
* Initialize authentication state on app startup
*/
private async initializeAuth(): Promise<void> {
console.log('[AuthService] Initializing authentication...');
if (this.isNative) {
// Check for pending redirect (deep link)
await this.handleRedirectPromise();
} else {
// Web: Standard MSAL redirect handling
await this.msalInstance.handleRedirectPromise();
}
// Set initial auth state
const accounts = this.msalInstance.getAllAccounts();
if (accounts.length > 0) {
this.msalInstance.setActiveAccount(accounts[0]);
this.isAuthenticatedSubject.next(true);
this.currentUserSubject.next(accounts[0]);
console.log('[AuthService] User authenticated:', accounts[0].username);
} else {
console.log('[AuthService] No authenticated user found');
}
}
/**
* Handle redirect promise (deep link on native, standard redirect on web)
*/
private async handleRedirectPromise(): Promise<AuthenticationResult | null> {
try {
if (this.isNative) {
// Native: Check localStorage for pending redirect URL
const pendingUrl = localStorage.getItem('msal_pending_redirect_url');
if (pendingUrl) {
console.log('[AuthService] Processing pending redirect:', pendingUrl);
localStorage.removeItem('msal_pending_redirect_url');
// Process the URL with MSAL
const result = await this.msalInstance.handleRedirectPromise(pendingUrl);
if (result) {
this.msalInstance.setActiveAccount(result.account);
this.isAuthenticatedSubject.next(true);
this.currentUserSubject.next(result.account);
console.log('[AuthService] Login successful:', result.account.username);
// Navigate to home after successful login
this.router.navigate(['/']);
return result;
}
}
} else {
// Web: Standard MSAL redirect handling
const result = await this.msalInstance.handleRedirectPromise();
if (result) {
this.msalInstance.setActiveAccount(result.account);
this.isAuthenticatedSubject.next(true);
this.currentUserSubject.next(result.account);
console.log('[AuthService] Login successful:', result.account.username);
return result;
}
}
return null;
} catch (error) {
console.error('[AuthService] Error handling redirect:', error);
return null;
}
}
/**
* Login - opens browser for authentication
*/
async login(): Promise<void> {
console.log('[AuthService] Starting login...');
const loginRequest: PopupRequest = {
scopes: environment.auth.scopes,
prompt: 'select_account'
};
try {
if (this.isNative) {
// Native: Open system browser
await this.loginWithBrowser(loginRequest);
} else {
// Web: Use redirect flow
await this.msalInstance.loginRedirect(loginRequest);
}
} catch (error) {
console.error('[AuthService] Login error:', error);
throw error;
}
}
/**
* Native login: Open system browser for OAuth
*/
private async loginWithBrowser(loginRequest: PopupRequest): Promise<void> {
try {
// Build OAuth authorization URL
const authUrl = await this.buildAuthUrl(loginRequest);
console.log('[AuthService] Opening browser with URL:', authUrl);
// Open system browser
await Browser.open({ url: authUrl });
// Note: Deep link will be caught by native app and handled in AppDelegate/MainActivity
} catch (error) {
console.error('[AuthService] Error opening browser:', error);
throw error;
}
}
/**
* Build OAuth authorization URL manually
*/
private async buildAuthUrl(request: PopupRequest): Promise<string> {
const params = new URLSearchParams({
client_id: environment.auth.clientId,
response_type: 'code',
redirect_uri: environment.auth.redirectUri,
scope: request.scopes?.join(' ') || 'openid profile',
prompt: request.prompt || 'select_account',
state: this.generateRandomString(32),
nonce: this.generateRandomString(32)
});
return `${environment.auth.authority}/oauth2/v2.0/authorize?${params.toString()}`;
}
/**
* Generate random string for state/nonce
*/
private generateRandomString(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Logout - clears session and redirects to login
*/
async logout(): Promise<void> {
console.log('[AuthService] Logging out...');
const logoutRequest: EndSessionRequest = {
account: this.msalInstance.getActiveAccount() || undefined,
postLogoutRedirectUri: environment.auth.postLogoutRedirectUri
};
try {
// Clear local state
this.isAuthenticatedSubject.next(false);
this.currentUserSubject.next(null);
if (this.isNative) {
// Native: Clear cache and navigate
await this.msalInstance.clearCache();
this.router.navigate(['/login']);
} else {
// Web: Standard MSAL logout (redirects to Azure AD)
await this.msalInstance.logoutRedirect(logoutRequest);
}
} catch (error) {
console.error('[AuthService] Logout error:', error);
throw error;
}
}
/**
* Get access token (silent or interactive)
*/
getAccessToken(scopes?: string[]): Observable<string> {
const account = this.msalInstance.getActiveAccount();
if (!account) {
console.error('[AuthService] No active account');
return of('');
}
const silentRequest: SilentRequest = {
scopes: scopes || environment.auth.scopes,
account: account,
forceRefresh: false
};
return from(this.msalInstance.acquireTokenSilent(silentRequest)).pipe(
map((result: AuthenticationResult) => {
console.log('[AuthService] Token acquired silently');
return result.accessToken;
}),
catchError((error) => {
console.warn('[AuthService] Silent token acquisition failed:', error);
if (error instanceof InteractionRequiredAuthError) {
// Interaction required - redirect to login
console.log('[AuthService] Interaction required, redirecting to login...');
this.login();
}
return of('');
})
);
}
/**
* Get current account info
*/
getAccount(): AccountInfo | null {
return this.msalInstance.getActiveAccount();
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return this.msalInstance.getAllAccounts().length > 0;
}
}
Deep Link Handling (iOS)
iOS requires native code to handle deep links (OAuth redirects).
Step 1: Configure Info.plist
ios/App/App/Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>msauth.com.yourcompany.yourapp</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>msauthv2</string>
<string>msauthv3</string>
</array>
Step 2: Handle Deep Links in AppDelegate
ios/App/App/AppDelegate.swift
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
// MARK: - Deep Link Handling
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
print("🔗 [DEEP LINK] App opened with URL: \(url.absoluteString)")
// Check if it's an MSAL redirect
if url.absoluteString.contains("msauth.com.yourcompany.yourapp://auth") {
print("✅ [DEEP LINK] MSAL redirect detected")
// Get Capacitor WebView
if let bridge = (window?.rootViewController as? CAPBridgeViewController)?.bridge,
let webView = bridge.webView {
let fullUrl = url.absoluteString
// Store URL in localStorage and reload
let jsCode = """
(function() {
console.log('🔗 [iOS→JS] Storing MSAL URL in localStorage: \(fullUrl)');
localStorage.setItem('msal_pending_redirect_url', '\(fullUrl)');
console.log('✅ [iOS→JS] Stored, reloading page...');
window.location.reload();
})();
"""
webView.evaluateJavaScript(jsCode) { result, error in
if let error = error {
print("❌ [DEEP LINK] Failed to execute JS: \(error)")
} else {
print("✅ [DEEP LINK] Successfully stored URL and triggered reload")
}
}
} else {
print("⚠️ [DEEP LINK] Could not get Capacitor bridge or WebView")
}
}
// Capacitor's default deep link handler
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Universal Links handling
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}
What this does:
- Intercepts deep link URL when app opens
- Stores the OAuth redirect URL in localStorage
- Reloads the page so MSAL can process the redirect
Deep Link Handling (Android)
Android requires similar deep link handling in MainActivity.
Step 1: Configure AndroidManifest.xml
android/app/src/main/AndroidManifest.xml
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep Link Intent Filter for MSAL -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="msauth"
android:host="com.yourcompany.yourapp"
android:path="/signature" />
</intent-filter>
</activity>
Step 2: Handle Deep Links in MainActivity
android/app/src/main/java/com/yourcompany/yourapp/MainActivity.java
package com.yourcompany.yourapp;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Handle deep link on app launch
handleDeepLink(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
// Handle deep link when app is already running
handleDeepLink(intent);
}
private void handleDeepLink(Intent intent) {
if (intent == null) return;
Uri data = intent.getData();
if (data != null) {
String url = data.toString();
Log.d(TAG, "🔗 [DEEP LINK] Received: " + url);
// Check if it's an MSAL redirect
if (url.contains("msauth://com.yourcompany.yourapp")) {
Log.d(TAG, "✅ [DEEP LINK] MSAL redirect detected");
// Store URL in localStorage via JavaScript
String jsCode = String.format(
"(function() {" +
" console.log('🔗 [Android→JS] Storing MSAL URL: %s');" +
" localStorage.setItem('msal_pending_redirect_url', '%s');" +
" console.log('✅ [Android→JS] Stored, reloading...');" +
" window.location.reload();" +
"})();",
url, url
);
bridge.getWebView().post(() -> {
bridge.getWebView().evaluateJavascript(jsCode, null);
});
}
}
}
}
Login Flow
Login Component
src/app/pages/login/login.component.ts
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../auth/auth.service';
@Component({
selector: 'app-login',
template: `
<div class="login-container">
<div class="login-card">
<h1>Welcome</h1>
<p>Please sign in to continue</p>
<button
class="btn-login"
(click)="login()"
[disabled]="isLoggingIn">
<span *ngIf="!isLoggingIn">Sign in with Microsoft</span>
<span *ngIf="isLoggingIn">Signing in...</span>
</button>
<p class="error" *ngIf="errorMessage">
{{ errorMessage }}
</p>
</div>
</div>
`,
styles: [`
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
width: 100%;
}
h1 {
margin-bottom: 0.5rem;
color: #333;
}
p {
color: #666;
margin-bottom: 2rem;
}
.btn-login {
background: #0078d4;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
width: 100%;
transition: background 0.3s;
}
.btn-login:hover:not(:disabled) {
background: #006cbd;
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #d32f2f;
margin-top: 1rem;
}
`]
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
isLoggingIn = false;
errorMessage = '';
async login(): Promise<void> {
try {
this.isLoggingIn = true;
this.errorMessage = '';
await this.authService.login();
// Note: Redirect will happen automatically after OAuth completes
} catch (error: any) {
this.errorMessage = 'Login failed. Please try again.';
console.error('[Login] Error:', error);
this.isLoggingIn = false;
}
}
}
Auth Guard
Protect routes that require authentication:
src/app/auth/auth.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from './auth.service';
import { map, take } from 'rxjs/operators';
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.isAuthenticated$.pipe(
take(1),
map(isAuthenticated => {
if (isAuthenticated) {
return true;
} else {
console.log('[AuthGuard] User not authenticated, redirecting to login');
router.navigate(['/login']);
return false;
}
})
);
};
Usage in routes:
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{
path: 'dashboard',
component: DashboardComponent,
canActivate: [authGuard] // Protected route
},
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: '**', redirectTo: '/login' }
];
Token Management
HTTP Interceptor
Automatically attach JWT tokens to API requests:
src/app/auth/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { switchMap, take, catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
// Skip token for non-API requests
if (!req.url.includes('/api/')) {
return next(req);
}
// Get token and attach to request
return authService.getAccessToken().pipe(
take(1),
switchMap(token => {
if (token) {
const clonedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
return next(clonedRequest);
} else {
return next(req);
}
}),
catchError(error => {
console.error('[AuthInterceptor] Error getting token:', error);
return throwError(() => error);
})
);
};
Register interceptor in app config:
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor])
)
]
};
Testing the Implementation
Manual Testing Checklist
Web (Browser)
Login Flow:
npm start # Navigate to http://localhost:4200 # Click "Sign in with Microsoft" # Should redirect to Microsoft login # After login, should redirect back to appCheck Console:
[AuthService] Initializing authentication... [AuthService] Login successful: user@example.com [AuthService] Token acquired silentlyVerify Token:
- Open DevTools → Application → Local Storage
- Should see MSAL cache entries
iOS
Build and Run:
npm run build npx cap sync ios npx cap open ios # Run from Xcode on physical device or simulatorTest Login:
- Tap "Sign in with Microsoft"
- Should open Safari browser
- Complete login
- Should redirect back to app
- Check Xcode console for deep link logs
Expected Console Output:
🔗 [DEEP LINK] App opened with URL: msauth.com.yourcompany.yourapp://auth?code=... ✅ [DEEP LINK] MSAL redirect detected ✅ [DEEP LINK] Successfully stored URL and triggered reload [AuthService] Processing pending redirect [AuthService] Login successful: user@example.com
Android
Build and Run:
npm run build npx cap sync android npx cap open android # Run from Android Studio on emulator or deviceTest Login:
- Same flow as iOS
- Check Android Logcat for deep link logs
Automated Tests
Unit test for AuthService:
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { MSAL_INSTANCE } from './msal-config.factory';
describe('AuthService', () => {
let service: AuthService;
let msalInstanceMock: any;
beforeEach(() => {
msalInstanceMock = {
getAllAccounts: jasmine.createSpy('getAllAccounts').and.returnValue([]),
handleRedirectPromise: jasmine.createSpy('handleRedirectPromise').and.returnValue(Promise.resolve(null)),
setActiveAccount: jasmine.createSpy('setActiveAccount')
};
TestBed.configureTestingModule({
providers: [
AuthService,
{ provide: MSAL_INSTANCE, useValue: msalInstanceMock }
]
});
service = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should initialize with no authenticated user', (done) => {
service.isAuthenticated$.subscribe(isAuth => {
expect(isAuth).toBeFalse();
done();
});
});
});
Troubleshooting
Common Issues
1. "Redirect URI mismatch"
Error:
AADSTS50011: The redirect URI 'msauth.com.yourcompany.yourapp://auth'
specified in the request does not match the redirect URIs configured
for the application.
Solution:
- Double-check redirect URI in Azure AD App Registration
- Ensure it exactly matches the one in your code
- iOS: Check Info.plist CFBundleURLSchemes
- Android: Check AndroidManifest.xml intent-filter
2. Deep link not opening app
iOS:
- Verify URL scheme is registered in Info.plist
- Check that AppDelegate.swift handles the URL
- Test with:
xcrun simctl openurl booted "msauth.com.yourcompany.yourapp://auth"
Android:
- Verify intent-filter in AndroidManifest.xml
- Check MainActivity.java handles the intent
- Test with:
adb shell am start -W -a android.intent.action.VIEW -d "msauth://com.yourcompany.yourapp/signature"
3. Token acquisition fails silently
Cause: MSAL silent token refresh requires iframe, which doesn't work in Capacitor WebView
Solution:
// In auth.service.ts, modify acquireTokenSilent:
const silentRequest: SilentRequest = {
scopes: scopes || environment.auth.scopes,
account: account,
forceRefresh: true // Always use refresh token, not iframe
};
4. "localStorage not defined" error
Cause: MSAL tries to access localStorage before it's available
Solution:
// In msal-config.factory.ts:
cache: {
cacheLocation: typeof localStorage !== 'undefined'
? BrowserCacheLocation.LocalStorage
: BrowserCacheLocation.MemoryStorage,
storeAuthStateInCookie: false
}
5. CORS errors with MSAL and API
Cause: Azure AD (login.microsoftonline.com) rejects capacitor://localhost origin during MSAL token exchange
🔴 CRITICAL SOLUTION - CapacitorHttp Config:
Native apps MUST enable CapacitorHttp in capacitor.config.ts. This automatically patches ALL HTTP requests (including MSAL token exchange with Azure AD!) to use native HTTP which bypasses CORS completely.
⚠️ WITHOUT THIS CONFIG, MSAL AUTHENTICATION WILL FAIL WITH CORS ERROR!
The problem:
MSAL.js → fetch('https://login.microsoftonline.com/token')
Request Origin: capacitor://localhost
Azure AD Response: CORS error (Origin not allowed)
Result: Authentication fails ❌
The solution:
MSAL.js → CapacitorHttp patch → Native HTTP (no Origin header)
Azure AD Response: 200 OK ✅
Result: Authentication succeeds!
File: capacitor.config.ts
const config: CapacitorConfig = {
appId: 'com.yourcompany.yourapp',
appName: 'YourApp',
webDir: 'dist',
plugins: {
CapacitorHttp: {
enabled: true, // 🔴 CRITICAL: Enable native HTTP (bypasses CORS)
},
},
};
export default config;
Why this works:
- ✅ Zero code changes - MSAL.js works without modifications
- ✅ Automatic patching - All fetch/XHR/HttpClient requests use native HTTP
- ✅ No CORS errors - Native HTTP doesn't send Origin header
- ✅ Transparent - Your Angular code doesn't know the difference
What gets automatically patched:
- ✅ MSAL.js token requests
- ✅ Angular HttpClient
- ✅ JavaScript fetch()
- ✅ XMLHttpRequest
- ✅ All third-party libraries
❌ DON'T create wrapper services - they're unnecessary and won't help with MSAL!
Note about Backend CORS:
With CapacitorHttp: { enabled: true }, your backend doesn't need CORS configuration for native apps because:
- Native HTTP doesn't send Origin header
- CORS is a browser security feature, not native app concern
You only need CORS if:
- Testing in web browser (not native app)
- Supporting web version of your app alongside mobile
// ASP.NET Core - ONLY if you also have a web version
builder.Services.AddCors(options => {
options.AddPolicy("AllowWeb", policy => {
policy.WithOrigins("http://localhost:4200") // Web dev server only
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
// ❌ Don't add capacitor://localhost - it's never used with CapacitorHttp enabled!
Security Best Practices
1. Token Storage
DO:
- ✅ Use LocalStorage for native apps (persistent across restarts)
- ✅ Use SessionStorage for web apps (cleared on browser close)
- ✅ Let MSAL handle token storage (it encrypts sensitive data)
DON'T:
- ❌ Store tokens in plain cookies
- ❌ Store tokens in unencrypted files
- ❌ Log tokens to console in production
2. Scope Management
Principle of Least Privilege:
// Request only scopes your app needs
scopes: [
'user.read', // User profile
'api://your-api/api_access' // Your API access
]
// Don't request excessive scopes
// ❌ scopes: ['user.read', 'mail.send', 'files.readwrite', ...]
3. Token Refresh
Proactive refresh before expiration:
getAccessToken(scopes?: string[]): Observable<string> {
const silentRequest: SilentRequest = {
scopes: scopes || environment.auth.scopes,
account: account,
forceRefresh: false, // Let MSAL decide based on token expiry
// MSAL automatically refreshes if token expires in <5 minutes
};
return from(this.msalInstance.acquireTokenSilent(silentRequest)).pipe(
map(result => result.accessToken)
);
}
4. Logout Cleanup
Always clear all auth state:
async logout(): Promise<void> {
// 1. Clear MSAL cache
await this.msalInstance.clearCache();
// 2. Clear local state
this.isAuthenticatedSubject.next(false);
this.currentUserSubject.next(null);
// 3. Clear any app-specific storage
localStorage.removeItem('user_preferences');
// 4. Navigate to login
this.router.navigate(['/login']);
}
5. Production Configuration
Disable verbose logging:
system: {
loggerOptions: {
logLevel: environment.production ? LogLevel.Error : LogLevel.Verbose
}
}
Use HTTPS in production:
// environment.prod.ts
auth: {
redirectUri: 'https://yourapp.com', // Not http://
postLogoutRedirectUri: 'https://yourapp.com/login'
}
Conclusion
Implementing MSAL authentication in a Capacitor Angular app requires careful handling of platform differences, deep links, and token management. This guide covered:
✅ Azure AD app registration
✅ MSAL configuration for web and native
✅ Authentication service with platform detection
✅ Deep link handling (iOS and Android)
✅ Login/logout flows
✅ Token management and refresh
✅ HTTP interceptor for API requests
✅ Testing and troubleshooting
✅ Security best practices
Key Takeaways
- Platform Detection is Critical - Always check
Capacitor.isNativePlatform()and handle web/native differently - Deep Links Are Essential - Native apps can't use standard redirects; OAuth must go through deep links
- localStorage Bridge - Use localStorage to pass OAuth redirects from native code to MSAL
- Silent Refresh Requires Refresh Tokens - iframes don't work in WebView, so use refresh tokens
- Test on Physical Devices - Simulators/emulators may not handle deep links correctly
Next Steps
- Add biometric authentication (fingerprint/Face ID)
- Implement token caching optimization
- Add offline authentication support
- Set up automated E2E tests
- Monitor authentication analytics
Resources
- MSAL.js Documentation
- Capacitor Documentation
- Azure AD Authentication
- OAuth 2.0 Authorization Code Flow
Published: February 16, 2026
Author: Václav Švára
Generated with assistance from: Claude (Anthropic AI)
License: MIT
Did you find this guide helpful? Share your experience or questions in the comments below!
Komentáře
Okomentovat