Entra External ID Login in Ionic: A Capacitor MSAL Plugin Tutorial
Tutorials ionic capacitor entra-external-id msal authentication

Entra External ID Login in Ionic: A Capacitor MSAL Plugin Tutorial

D. Rout

D. Rout

October 10, 2026 17 min read

On this page

Adding "Sign in" to a hybrid app sounds like an afternoon's work until you try to do it against Microsoft Entra External ID on three platforms at once. The browser needs a redirect flow, iOS wants the Keychain set up just so, Android wants a signature hash in two different encodings, and every one of them caches tokens differently. I built @nativelement/capacitor-msal-entra (on npm) to collapse that into one small API: signIn, acquireTokenSilent, getAccount and signOut.

In this tutorial we wire that plugin into an Ionic + Angular app, end to end: tenant registration, the native setup for Android and iOS, the web fallback, an Angular auth service, a route guard, an HTTP interceptor, and backend token validation. The complete, compiling sample lives in a public repo: deepakrout/msal-entra-ionic-demo. Along the way I'll point out the setup failures that cost the most time, collected into a table you can search when you hit them.

What is Microsoft Entra External ID?

Entra External ID is Microsoft's customer-identity (CIAM) offering, the successor to Azure AD B2C for new projects. You create a dedicated external tenant, define a user flow (sign up and sign in, with email and password, email one-time code, or social providers such as Google, Apple and Facebook), and register your apps against it. Users are customers, not employees, and they live in that tenant rather than in your workforce directory.

Three details matter for mobile developers:

  • Hosted sign-in page. Your app never renders a password field. It opens the tenant's branded page in the system browser (ASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android) and receives tokens back through a redirect.
  • CIAM authority. Your authority URL looks like https://<subdomain>.ciamlogin.com/<subdomain>.onmicrosoft.com/, not login.microsoftonline.com.
  • Standard OAuth 2.0 + OIDC with PKCE. Tokens are JWTs signed with RS256 that your API validates against the tenant's JWKS endpoint.

Why a native plugin instead of a hand-rolled flow?

You can absolutely drive the authorization-code flow yourself with @capacitor/browser and a PKCE helper. It works until you need secure token storage, refresh-token handling and account bookkeeping. The Microsoft SDKs already solve those. The plugin wraps MSAL Android and MSAL iOS natively and @azure/msal-browser on the web, so you inherit their cache and renewal logic instead of reimplementing it.

Concern

Hand-rolled browser + PKCE

This plugin (native MSAL)

Token storage

You choose and secure it (Preferences is not secure storage)

Keychain on iOS, encrypted storage on Android, localStorage on web

Silent renewal

You implement refresh-token rotation and races

acquireTokenSilent() with a UI_REQUIRED signal

Redirect handling

Custom URL scheme plumbing and state/nonce checks

Handled by the SDKs

Web / PWA

A second implementation

Same API, msal-browser underneath

Setup effort

Less native config, much more JS

More native config (covered below), almost no auth code

My opinionated take: unless you have a requirement MSAL cannot meet, don't write token-refresh code. The native setup is fiddly, but it is a one-time cost and failures are loud. A subtle bug in hand-written refresh logic is quiet and shows up in production.

Prerequisites

  • Node 20+ and npm, plus an Ionic/Angular app (the sample uses Ionic 8, Angular 20 standalone components and Capacitor 7; the plugin's peer dependency is @capacitor/core >= 6).
  • An Entra External ID external tenant with a Sign up and sign in user flow.
  • Xcode and CocoaPods for iOS; Android Studio and JDK for Android.
  • New to Capacitor plugins? Skim Building a Capacitor Plugin From Scratch first to see how the JS-to-native bridge works; it makes the setup steps below less mysterious.

Step 1: Register one app with three platform entries

In the external tenant, create a single app registration and add a platform entry for each runtime. Using one registration keeps one client ID story, but the redirect URIs differ:

Platform entry

Redirect URI

Notes

Single-page application

http://localhost:4200 (and your deployed PWA origin)

Must be SPA, not "Web"

iOS / macOS

msauth.<bundle-id>://auth

Bundle ID must match Xcode

Android

msauth://<package>/<signature-hash>

Debug and release hashes differ

Then add the app to your user flow under External Identities → User flows → Applications. If you want your own API, expose a delegated scope such as api://<api-client-id>/access_as_user and grant it to the app.

Step 2: Install the plugin

npm install @nativelement/capacitor-msal-entra
npm run build
npx cap add android
npx cap add ios
npx cap sync

The package exposes a single object, MsalAuth, with typed options and results. Here is the whole public surface:

import { MsalAuth } from '@nativelement/capacitor-msal-entra';

MsalAuth.signIn({ scopes, domainHint?, prompt? })        // interactive
MsalAuth.acquireTokenSilent({ scopes })                  // cache + refresh token
MsalAuth.getAccount()                                    // { value: AccountInfo | null }
MsalAuth.signOut({ clearBrowserSession? })               // local, or local + SSO

Step 3: Configure Android

Three pieces: a Maven repository, a config file, and a manifest activity.

3a. Add the Duo SDK feed. MSAL pulls in com.microsoft.device.display:display-mask, which is published only to a Microsoft-hosted Azure DevOps feed. It must be declared in the app's root android/build.gradle; declaring it inside the plugin module is not enough, because Gradle resolves the app's full classpath with the app's own repository list. (If Gradle is still a black box, this Gradle guide explains why.)

allprojects {
    repositories {
        google()
        mavenCentral()
        maven {
            url 'https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1'
            name 'Duo-SDK-Feed'
        }
    }
}

3b. Create android/app/src/main/res/raw/msal_config.json.

{
  "client_id": "<ANDROID_APP_CLIENT_ID>",
  "authorization_user_agent": "DEFAULT",
  "redirect_uri": "msauth://com.example.msalentrademo/<URL_ENCODED_SIGNATURE_HASH>",
  "account_mode": "MULTIPLE",
  "broker_redirect_uri_registered": false,
  "authorities": [
    {
      "type": "CIAM",
      "default": true,
      "authority_url": "https://<tenant>.ciamlogin.com/<tenant>.onmicrosoft.com/"
    }
  ]
}

3c. Add the redirect activity to AndroidManifest.xml.

<activity
    android:name="com.microsoft.identity.client.BrowserTabActivity"
    android:exported="true">
    <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.example.msalentrademo"
            android:path="/<RAW-signature-hash>" />
    </intent-filter>
</activity>

The encoding trap. The same signature hash appears twice and must be written differently. In msal_config.json it is URL-encoded (+→%2B, /→%2F, =→%3D). In the manifest, android:path takes the raw hash with a leading /. Getting them backwards breaks the redirect. Also generate a hash for your debug keystore and your release keystore and register both; this is the single most common MSAL Android setup failure.

Step 4: Configure iOS

4a. Use CocoaPods. The plugin ships a podspec rather than a Package.swift, so Capacitor's Swift Package Manager integration can't include it. If ios/App/CapApp-SPM exists, remove it and its package reference in Xcode, then add a classic Capacitor Podfile. After that, npx cap sync ios injects the plugin pod automatically and MSAL arrives as a transitive dependency. (For background on why SPM and CocoaPods behave differently, see Shipping Code to the World: iOS & macOS Library Distribution.)

4b. Add msal_config.json to the Xcode target:

{
  "client_id": "<IOS_APP_CLIENT_ID>",
  "redirect_uri": "msauth.com.example.msalentrademo://auth",
  "authority_url": "https://<tenant>.ciamlogin.com/<tenant>.onmicrosoft.com/"
}

4c. Info.plist needs your redirect scheme, plus the broker schemes if you want Microsoft Authenticator support:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array><string>msauth.com.example.msalentrademo</string></array>
  </dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
  <string>msauthv2</string>
  <string>msauthv3</string>
</array>

4d. Keychain Sharing. This is the one people skip. Add the entitlement below (Xcode → Signing & Capabilities → Keychain Sharing). Without it, interactive signIn() still appears to work because the result comes back in memory, but every call that reads the cache fails with OSStatus -34018.

<key>keychain-access-groups</key>
<array>
  <string>$(AppIdentifierPrefix)com.microsoft.adalcache</string>
</array>

Step 5: Configure the web build

On ionic serve, in a browser tab or in an installed PWA, the plugin falls back to @azure/msal-browser. Create src/assets/msal_config.json:

{
  "client_id": "<WEB_APP_CLIENT_ID>",
  "authority_url": "https://<tenant>.ciamlogin.com/<tenant>.onmicrosoft.com/",
  "redirect_uri": "http://localhost:4200",
  "known_authorities": []
}

Two behaviours differ from native, and your code has to respect both:

  • signIn() uses a full-page redirect. The browser navigates away and the promise never resolves; the JS context is destroyed and rebuilt when Entra redirects back.
  • That means the result reaches your app through getAccount() on startup, not through signIn()'s return value. Don't block UI on that promise settling on web.

Step 6: Build a signal-based AuthService

Wrap the plugin once and expose signals, so components react without subscriptions. This version follows the Angular patterns from Angular Signals: The Complete Developer's Guide.

@Injectable({ providedIn: 'root' })
export class AuthService {
  private readonly _account = signal<AccountInfo | null>(null);
  readonly account = this._account.asReadonly();
  readonly isSignedIn = computed(() => this._account() !== null);
  readonly isNative = Capacitor.isNativePlatform();

  async restoreSession(): Promise<void> {
    const { value } = await MsalAuth.getAccount();   // unwrap { value }
    this._account.set(value);
  }

  async signIn(provider?: 'Google' | 'apple' | 'facebook'): Promise<void> {
    await MsalAuth.signIn({
      scopes: API_SCOPES,
      ...(provider ? { domainHint: provider } : {}),
    });
    // Native: we get here. Web: the page already navigated away.
    await this.restoreSession();
  }

  async getAccessToken(): Promise<string> {
    const r = await MsalAuth.acquireTokenSilent({ scopes: API_SCOPES });
    return r.accessToken;
  }

  async signOut(clearBrowserSession = false): Promise<void> {
    await MsalAuth.signOut({ clearBrowserSession });
    this._account.set(null);
  }
}

Two scope rules are worth memorising. Never pass openid, profile or offline_access: MSAL adds them itself and iOS rejects them outright. And for a basic sign-in that only needs an ID token, pass an empty array. Android's MSAL throws on an empty list, so the plugin quietly substitutes openid there and you can use [] on every platform.

Step 7: Restore the session and guard routes

Call restoreSession() from the root component. It reads the local cache without a network call, and on web it is the moment a redirect sign-in completes.

export class AppComponent implements OnInit {
  private readonly auth = inject(AuthService);
  async ngOnInit() { await this.auth.restoreSession(); }
}

export const authGuard: CanActivateFn = async () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  if (!auth.isSignedIn()) await auth.restoreSession(); // deep links
  return auth.isSignedIn() ? true : router.createUrlTree(['/home']);
};

Step 8: Attach tokens with an HTTP interceptor

An interceptor asks for a fresh token before each API call. Two decisions here are deliberate. First, only attach the token to your own API origin, never to third-party hosts. Second, when silent renewal fails with UI_REQUIRED, route the user to a sign-in screen instead of launching signIn() from inside the pipeline. On web, an interactive sign-in from an interceptor navigates the entire app away mid-request.

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  if (!req.url.startsWith(API_BASE_URL)) return next(req);

  const auth = inject(AuthService);
  const router = inject(Router);

  return from(auth.getAccessToken()).pipe(
    switchMap(token =>
      next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))),
    catchError(err => {
      if (err?.code === 'UI_REQUIRED') router.navigateByUrl('/home');
      return throwError(() => err);
    }),
  );
};

Register it in main.ts with provideHttpClient(withInterceptors([authInterceptor])).

Step 9: Skip the picker with social sign-in

If your user flow has Google, Apple or Facebook enabled, pass domainHint to jump straight to that provider and bypass the Entra picker page:

await MsalAuth.signIn({ scopes: [], domainHint: 'Google' }); // capital G
await MsalAuth.signIn({ scopes: [], domainHint: 'apple' });

The values are bare provider names, and 'Google' is case-sensitive: 'google' fails with AADSTS90023. Do not pass 'apple.com'. A domain-shaped value triggers a different mechanism (domain acceleration for custom SAML/WS-Fed providers) and can misroute the user to Apple's business identity portal instead of Sign in with Apple. Also note that domainHint currently works on iOS and web only; the Android implementation ignores it.

Step 10: Validate the token on your API

The access token is only useful if your backend refuses forged ones. Never decode-and-trust on the client. A CIAM token has issuer https://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0, an audience equal to your API's client ID (or Application ID URI), and a scp claim listing the bare scope names (read write, not the full api:// URI). Here is the Express version using express-jwt and jwks-rsa; if you're building that API now, the Express + TypeScript REST API guide covers the surrounding structure.

const ISSUER = `https://${TENANT_ID}.ciamlogin.com/${TENANT_ID}/v2.0`;

const checkJwt = jwt({
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: `${ISSUER}/.well-known/openid-configuration`,
  }),
  issuer: ISSUER,
  audience: API_CLIENT_ID,
  algorithms: ['RS256'],
});

app.get('/api/widgets', checkJwt, requireScope('read', 'write'), handler);

Reference: API surface and the failures you'll actually hit

First the options, then the symptoms. The second table is the part I'd bookmark: each row is a failure with a distinctive symptom and a non-obvious cause.

Option / method

iOS

Android

Web

Notes

signIn({ scopes })

Yes

Yes

Yes (redirect)

Promise never resolves on web

domainHint

Yes

No

Yes

'Google', 'apple', 'facebook'

acquireTokenSilent()

Yes

Yes

Yes

Rejects with UI_REQUIRED

getAccount()

Yes

Yes

Yes

Returns { value }; no network call

signOut({ clearBrowserSession })

Yes

Yes

Yes

true also ends Entra SSO; never resolves on web

Symptom

Platform

Cause

Fix

:app:assembleDebug fails at checkDebugAarMetadata, can't resolve display-mask

Android

Duo SDK feed missing from the app's repositories

Add the Maven feed to root allprojects.repositories

Sign-in never completes after the browser step

Android

Hash encoded in the manifest, or raw in msal_config.json

Encoded in config; raw with leading / in manifest

Works in debug, fails in release

Android

Release keystore has a different signature hash

Register both redirect URIs

OSStatus -34018 on getAccount, acquireTokenSilent, signOut

iOS

Keychain Sharing entitlement missing

Add the com.microsoft.adalcache access group

Plugin not picked up by cap sync

iOS

App uses SPM; plugin ships only a podspec

Move the whole app to CocoaPods

Opaque CORS error on the token endpoint

Web

Redirect registered under "Web" platform

Register it under Single-page application

Spinner forever after tapping Sign in

Web

Awaiting a promise that never resolves

Read state from getAccount() on startup

AADSTS90023: 'google' '' pair is not an external identity provider

iOS, Web

Lower-case provider name

Use 'Google'

Social hint sends user to the wrong portal

iOS, Web

Used 'apple.com' (domain acceleration)

Use bare 'apple'

iOS rejects scopes immediately

iOS

Passed openid/profile/offline_access

Remove them; pass [] for ID-token-only

"Forgot password?" link never appears

All

Link is hidden until company branding enables it

Enable Email OTP, then tick "Show self-service password reset" in branding

What's next

  • Add Apple and Google to the tenant and compare the first-party picker with the domainHint shortcut in your own conversion funnel.
  • Handle multi-account switching. Both native SDKs support it; the plugin currently assumes one signed-in user at a time.
  • Harden the session UX. Replace the redirect-on-UI_REQUIRED with a "session expired" screen that preserves in-progress form state.
  • Plan for SSR if you also ship a web build. Browser-only auth APIs need care on the server; the Angular SSR explainer covers why.

Further reading

Wrapping up

You now have a single auth service that signs customers in against Entra External ID on iOS, Android and the web, renews tokens silently, protects routes, secures API calls and has a backend that verifies what it receives. The sample app is in deepakrout/msal-entra-ionic-demo, and the plugin itself, with its full README and changelog, is at NativElement/capacitor-msal-entra. Issues and pull requests are welcome, especially Android domainHint support.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!