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.


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)
  • ✅ 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

  1. Go to Azure Portal
  2. Navigate to Azure Active Directory → App 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)
  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)

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:

  1. 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
  1. 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:

  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;
  }
}

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>

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:

  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

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>

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)

  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
    
  2. Check Console:

    [AuthService] Initializing authentication...
    [AuthService] Login successful: user@example.com
    [AuthService] Token acquired silently
    
  3. 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
    
  2. 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
  3. 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

  1. Build and Run:

    npm run build
    npx cap sync android
    npx cap open android
    # Run from Android Studio on emulator or device
    
  2. 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();
    });
  });
});

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

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.comrejects 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

  1. Platform Detection is Critical - Always check Capacitor.isNativePlatform() and handle web/native differently
  2. Deep Links Are Essential - Native apps can't use standard redirects; OAuth must go through deep links
  3. localStorage Bridge - Use localStorage to pass OAuth redirects from native code to MSAL
  4. Silent Refresh Requires Refresh Tokens - iframes don't work in WebView, so use refresh tokens
  5. 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!

Komentáře

Populární příspěvky z tohoto blogu

DevExpress BarEditItem Closing-Editor at runtime to obtain entered value

CSS - Exact same height and alignment of text and input text box in major browsers