DEV Community

Vaclav Svara
Vaclav Svara

Posted on

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

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.

⚠️ IMPORTANT: Before starting, read the CRITICAL: CapacitorHttp Configuration section. This single configuration prevents the #1 cause of MSAL authentication failures in Capacitor apps (CORS errors).


The Challenge

Why is MSAL different in Capacitor?

  1. Custom URL Schemes: Capacitor apps run on capacitor://localhost instead of https:// domains
  2. Deep Link Redirects: OAuth redirects must be handled by native app deep links
  3. Browser Context: MSAL expects a standard browser environment, but Capacitor uses a WebView
  4. Token Storage: Secure token storage differs between web and native platforms
  5. 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)
  • Bypasses CORS issues with CapacitorHttp (critical!)
  • ✅ Handles OAuth2 redirect flow properly
  • ✅ Stores tokens securely
  • ✅ Automatically refreshes expired tokens
  • ✅ Attaches JWT tokens to API requests
  • ✅ Handles login/logout gracefully

🔴 CRITICAL: CapacitorHttp Configuration

⚠️ READ THIS FIRST - Authentication Will Fail Without It!

This is the #1 reason MSAL fails in Capacitor apps. Before implementing anything else, you MUST configure CapacitorHttp, or authentication will fail with CORS errors.

The Problem

Azure AD (login.microsoftonline.com) rejects the capacitor://localhost origin during MSAL token exchange:

MSAL.js → fetch('https://login.microsoftonline.com/token')
Request Origin: capacitor://localhost
Azure AD Response: ❌ CORS error (Origin not allowed)
Result: ❌ Authentication fails
Enter fullscreen mode Exit fullscreen mode

The Solution

Enable CapacitorHttp in your Capacitor config. This automatically patches ALL HTTP requests (including MSAL's token exchange) to use native HTTP, which bypasses CORS entirely:

MSAL.js → CapacitorHttp patch → Native HTTP (no Origin header)
Azure AD Response: ✅ 200 OK
Result: ✅ Authentication succeeds!
Enter fullscreen mode Exit fullscreen mode

Required Configuration

File: capacitor.config.ts

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.yourcompany.yourapp',
  appName: 'YourApp',
  webDir: 'dist',

  plugins: {
    CapacitorHttp: {
      enabled: true, // 🔴 CRITICAL: Enable native HTTP (bypasses CORS)
    },
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

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

With CapacitorHttp: { enabled: true }, these are automatically converted to native HTTP:

  • MSAL.js token requests (Azure AD communication)
  • Angular HttpClient (your API calls)
  • JavaScript fetch() (any library using fetch)
  • XMLHttpRequest (legacy AJAX)
  • All third-party libraries (automatic)

❌ Common Mistakes

DON'T create wrapper services:

// ❌ WRONG - Unnecessary and won't help MSAL
export class HttpWrapperService {
  // Wrapper services don't help because MSAL uses fetch() directly
}
Enter fullscreen mode Exit fullscreen mode

DON'T add capacitor://localhost to backend CORS:

// ❌ WRONG - Will never be used with CapacitorHttp enabled
builder.Services.AddCors(options => {
    options.AddPolicy("AllowCapacitor", policy => {
        policy.WithOrigins("capacitor://localhost")  // ❌ Useless
    });
});
Enter fullscreen mode Exit fullscreen mode

Backend CORS Configuration

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 a native app concern

You only need CORS if:

  • Testing in web browser (development)
  • Supporting web version (PWA) alongside mobile
// ASP.NET Core - ONLY for web browser/PWA support
builder.Services.AddCors(options => {
    options.AddPolicy("AllowWeb", policy => {
        policy.WithOrigins("http://localhost:4200")  // Web dev server only
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials();
    });
});

app.UseCors("AllowWeb");
Enter fullscreen mode Exit fullscreen mode

Verification

After enabling CapacitorHttp, verify it's working:

  1. Build and run on device:
   npm run build
   npx cap sync
   npx cap run ios  # or android
Enter fullscreen mode Exit fullscreen mode
  1. Check console logs:
   ✅ [MSAL] Token acquired successfully
   ✅ [AuthService] Login successful: user@example.com
Enter fullscreen mode Exit fullscreen mode
  1. If you see CORS errors, CapacitorHttp is NOT enabled correctly.

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    │
         └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Azure AD App Registration

Step 1: Create App Registration

  1. Go to Azure Portal
  2. Navigate to Azure Active DirectoryApp registrations
  3. 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)
Enter fullscreen mode Exit fullscreen mode
  1. 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)
Enter fullscreen mode Exit fullscreen mode

Save these values - you'll need them for configuration.

Step 3: Add Redirect URIs

Go to AuthenticationAdd a platform

For Web (PWA):

Platform: Single-page application (SPA)
Redirect URI: http://localhost:4200
              https://yourapp.com
Enter fullscreen mode Exit fullscreen mode

For iOS:

Platform: iOS / macOS
Redirect URI: msauth.com.yourcompany.yourapp://auth
Enter fullscreen mode Exit fullscreen mode

For Android:

Platform: Android
Redirect URI: msauth://com.yourcompany.yourapp/signature-hash
Enter fullscreen mode Exit fullscreen mode

Platform-agnostic (Capacitor):

Platform: Single-page application (SPA)
Redirect URI: capacitor://localhost
Enter fullscreen mode Exit fullscreen mode

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:

  1. Expose an APIAdd a scope
Scope name: api_access
Display name: Access API
Description: Allows the app to access the backend API
State: Enabled
Enter fullscreen mode Exit fullscreen mode
  1. Note the scope URI:
api://12345678-1234-1234-1234-123456789abc/api_access
Enter fullscreen mode Exit fullscreen mode

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'
  }
};
Enter fullscreen mode Exit fullscreen mode

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'
  }
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Key Configuration Points:

  1. navigateToLoginRequestUrl: false - Prevents MSAL from navigating after login (we handle it manually)
  2. cacheLocation - LocalStorage for native (persistent), SessionStorage for web
  3. allowNativeBroker: false - Disables native broker (Authenticator app) which doesn't work in WebView
  4. 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
        // Universal Links handling
        return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
    }
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  1. Intercepts deep link URL when app opens
  2. Stores the OAuth redirect URL in localStorage
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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);
                });
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
      }
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

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' }
];
Enter fullscreen mode Exit fullscreen mode

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);
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

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])
    )
  ]
};
Enter fullscreen mode Exit fullscreen mode

Testing the Implementation

Manual Testing Checklist

Web (Browser)

  1. 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 app
Enter fullscreen mode Exit fullscreen mode
  1. Check Console:
   [AuthService] Initializing authentication...
   [AuthService] Login successful: user@example.com
   [AuthService] Token acquired silently
Enter fullscreen mode Exit fullscreen mode
  1. Verify Token:
    • Open DevTools → Application → Local Storage
    • Should see MSAL cache entries

iOS

  1. Build and Run:
   npm run build
   npx cap sync ios
   npx cap open ios
   # Run from Xcode on physical device or simulator
Enter fullscreen mode Exit fullscreen mode
  1. Test Login:

    • Tap "Sign in with Microsoft"
    • Should open Safari browser
    • Complete login
    • Should redirect back to app
    • Check Xcode console for deep link logs
  2. 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
Enter fullscreen mode Exit fullscreen mode

Android

  1. Build and Run:
   npm run build
   npx cap sync android
   npx cap open android
   # Run from Android Studio on emulator or device
Enter fullscreen mode Exit fullscreen mode
  1. Test 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();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

5. CORS errors with MSAL and API

This is the #1 issue when implementing MSAL in Capacitor!

Cause: Azure AD (login.microsoftonline.com) rejects capacitor://localhost origin during MSAL token exchange

🔴 SOLUTION: See the CRITICAL: CapacitorHttp Configuration section at the top of this guide.

Quick summary:

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 ❌
Enter fullscreen mode Exit fullscreen mode

The solution:

MSAL.js → CapacitorHttp patch → Native HTTP (no Origin header)
Azure AD Response: 200 OK ✅
Result: Authentication succeeds!
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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', ...]
Enter fullscreen mode Exit fullscreen mode

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)
  );
}
Enter fullscreen mode Exit fullscreen mode

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']);
}
Enter fullscreen mode Exit fullscreen mode

5. Production Configuration

Disable verbose logging:

system: {
  loggerOptions: {
    logLevel: environment.production ? LogLevel.Error : LogLevel.Verbose
  }
}
Enter fullscreen mode Exit fullscreen mode

Use HTTPS in production:

// environment.prod.ts
auth: {
  redirectUri: 'https://yourapp.com',  // Not http://
  postLogoutRedirectUri: 'https://yourapp.com/login'
}
Enter fullscreen mode Exit fullscreen mode

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

  1. 🔴 CapacitorHttp is MANDATORY - Enable CapacitorHttp in capacitor.config.ts or MSAL will fail with CORS errors. This is the #1 issue and must be configured first.
  2. Platform Detection is Critical - Always check Capacitor.isNativePlatform() and handle web/native differently
  3. Deep Links Are Essential - Native apps can't use standard redirects; OAuth must go through deep links
  4. localStorage Bridge - Use localStorage to pass OAuth redirects from native code to MSAL
  5. Silent Refresh Requires Refresh Tokens - iframes don't work in WebView, so use refresh tokens
  6. 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


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!

Top comments (0)