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

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 (
ASWebAuthenticationSessionon 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/, notlogin.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, |
Silent renewal | You implement refresh-token rotation and races |
|
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 |
| Must be SPA, not "Web" |
iOS / macOS |
| Bundle ID must match Xcode |
Android |
| 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 throughsignIn()'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 |
| Yes | Yes | Yes (redirect) | Promise never resolves on web |
| Yes | No | Yes |
|
| Yes | Yes | Yes | Rejects with |
| Yes | Yes | Yes | Returns |
| Yes | Yes | Yes |
|
Symptom | Platform | Cause | Fix | |
| Android | Duo SDK feed missing from the app's repositories | Add the Maven feed to root | |
Sign-in never completes after the browser step | Android | Hash encoded in the manifest, or raw in | Encoded in config; raw with leading | |
Works in debug, fails in release | Android | Release keystore has a different signature hash | Register both redirect URIs | |
| iOS | Keychain Sharing entitlement missing | Add the | |
Plugin not picked up by | 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 | |
| iOS, Web | Lower-case provider name | Use | |
Social hint sends user to the wrong portal | iOS, Web | Used | Use bare | |
iOS rejects scopes immediately | iOS | Passed | Remove them; pass | |
"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
domainHintshortcut 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_REQUIREDwith 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
- Microsoft Entra External ID for customers: overview
- MSAL for Android on GitHub
- MSAL for iOS and macOS on GitHub
- @azure/msal-browser documentation
- Enable self-service password reset for customers
- Capacitor plugin documentation
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.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!