+1 (415) 843-4662

Enterprise SSO in Ionic apps: OAuth 2.1 + PKCE with Capacitor, Entra ID and Okta

Enterprise buyers rarely ask "can users log in?" — they ask "can our staff log in with their existing corporate account, and can IT revoke that access in thirty seconds?" For an Ionic app on Capacitor that means one thing: a real OAuth 2.1 authorization-code-plus-PKCE flow against the customer's identity provider (Microsoft Entra ID, Okta, Auth0, Ping, Keycloak), running in the system browser, with tokens stored in the platform keystore rather than localStorage.

This tutorial builds that flow end to end and then covers the parts that actually break in audits: silent renewal, single logout, conditional-access prompts, and what to do when the customer insists on their own tenant.

Our Passkeys in Ionic apps tutorial covers consumer passwordless sign-in. This one is its enterprise sibling — the two compose well, because the passkey usually lives at the IdP.

Why not an in-app WebView login form

Three reasons, and the first one is enough on its own.

  1. The IdP will refuse. Google, Microsoft and most enterprise IdPs block embedded-WebView user agents, and app review flags "collects corporate credentials in a WebView" as a phishing pattern.
  2. No SSO. A WebView has its own cookie jar, so the user re-authenticates even though they are already signed in to Outlook on the same device. The system browser shares the session; that is the SSO.
  3. No device policy. Conditional access, managed-device checks and step-up MFA rely on the platform browser and the broker app. In a WebView they either fail or silently fall back to a weaker policy.

So: @capacitor/browser (or the native ASWebAuthenticationSession / Android Custom Tabs behaviour it wraps) plus a redirect back into the app.

1. Register the app with the IdP

Create a public client (no client secret — a secret shipped in an app bundle is not a secret) and register a redirect URI. You have two choices.

Redirect styleExampleNotes
Custom schemecom.example.app://auth/callbackWorks everywhere, trivially registrable by another app
HTTPS App Link / Universal Linkhttps://app.example.com/auth/callbackVerified ownership, cannot be hijacked — prefer this

If you already set up verified deep links (see our deep links tutorial), reuse them here. Also enable refresh token rotation and set the token lifetimes: 15–60 minute access tokens, rotating refresh tokens with a sliding window your security reviewer is happy with.

2. Build the authorization request

PKCE in about forty lines, using Web Crypto, so it works identically in the browser during ionic serve and in the native WebView.

// src/auth/pkce.ts
function base64url(bytes: Uint8Array): string {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export function randomString(byteLength = 32): string {
  return base64url(crypto.getRandomValues(new Uint8Array(byteLength)));
}

export async function challengeFor(verifier: string): Promise<string> {
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(verifier),
  );
  return base64url(new Uint8Array(digest));
}
// src/auth/config.ts
export const authConfig = {
  // discovered from ${issuer}/.well-known/openid-configuration
  issuer: 'https://login.microsoftonline.com/<tenant-id>/v2.0',
  clientId: '<application-client-id>',
  redirectUri: 'https://app.example.com/auth/callback',
  scope: 'openid profile email offline_access api://example-api/user_impersonation',
};

Discover endpoints rather than hard-coding them — it is one fetch, and it is the difference between supporting one tenant and supporting every customer:

export async function discover(issuer: string) {
  const res = await fetch(`${issuer}/.well-known/openid-configuration`);
  if (!res.ok) throw new Error(`discovery failed: ${res.status}`);
  const doc = await res.json();
  return {
    authorizationEndpoint: doc.authorization_endpoint as string,
    tokenEndpoint: doc.token_endpoint as string,
    endSessionEndpoint: doc.end_session_endpoint as string | undefined,
    revocationEndpoint: doc.revocation_endpoint as string | undefined,
    jwksUri: doc.jwks_uri as string,
  };
}

3. Open the system browser and catch the redirect

// src/auth/login.ts
import { Browser } from '@capacitor/browser';
import { App } from '@capacitor/app';
import { Preferences } from '@capacitor/preferences';
import { authConfig, discover } from './config';
import { randomString, challengeFor } from './pkce';
import { saveTokens } from './store';

export async function login(): Promise<void> {
  const eps = await discover(authConfig.issuer);
  const verifier = randomString();
  const state = randomString(16);
  const nonce = randomString(16);

  // the verifier must survive a cold start: iOS may kill the app behind the browser
  await Preferences.set({
    key: 'auth.pending',
    value: JSON.stringify({ verifier, state, nonce }),
  });

  const url = new URL(eps.authorizationEndpoint);
  url.search = new URLSearchParams({
    client_id: authConfig.clientId,
    response_type: 'code',
    redirect_uri: authConfig.redirectUri,
    scope: authConfig.scope,
    state,
    nonce,
    code_challenge: await challengeFor(verifier),
    code_challenge_method: 'S256',
    prompt: 'select_account',
  }).toString();

  await Browser.open({ url: url.toString(), presentationStyle: 'popover' });
}

// register once, at app start
export function installAuthListener() {
  App.addListener('appUrlOpen', async ({ url }) => {
    const parsed = new URL(url);
    if (!parsed.pathname.startsWith('/auth/callback')) return;
    await Browser.close();
    await completeLogin(parsed.searchParams);
  });
}

Note the three things people forget:

  • Persist the verifier off the heap. iOS can terminate the app while ASWebAuthenticationSession is in front of it; an in-memory verifier is gone and login fails with invalid_grant on a cold device.
  • Browser.close() explicitly on Android Custom Tabs, or the user comes back to the browser when they hit the back button.
  • Handle the error redirect. ?error=access_denied for a cancelled consent, ?error=interaction_required for conditional access.

4. Exchange the code

// src/auth/complete.ts
export async function completeLogin(params: URLSearchParams): Promise<void> {
  const pending = JSON.parse(
    (await Preferences.get({ key: 'auth.pending' })).value ?? '{}',
  );
  await Preferences.remove({ key: 'auth.pending' });

  if (params.get('error')) throw new Error(params.get('error')!);
  if (!pending.state || params.get('state') !== pending.state) {
    throw new Error('state mismatch — possible injection, abort');
  }

  const eps = await discover(authConfig.issuer);
  const res = await fetch(eps.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: params.get('code')!,
      redirect_uri: authConfig.redirectUri,
      client_id: authConfig.clientId,
      code_verifier: pending.verifier,
    }),
  });
  if (!res.ok) throw new Error(`token exchange failed: ${await res.text()}`);

  const tokens = await res.json();   // access_token, refresh_token, id_token, expires_in
  await verifyIdToken(tokens.id_token, pending.nonce);
  await saveTokens(tokens);
}

Validate the ID token: signature against the JWKS, iss, aud, exp, and the nonce you sent. If you are already using a maintained library (@openid/appauth, angular-auth-oidc-client, oidc-client-ts), let it do this — hand-rolled JWT validation is the single most common place we find a real bug during a security review.

5. Store tokens where a rooted device can't read them

localStorage and @capacitor/preferences are plain files inside the app sandbox. Fine for a theme setting; not fine for a refresh token that mints access to corporate data. Use a keystore-backed plugin — Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly on iOS, EncryptedSharedPreferences or a StrongBox-backed key on Android — and wrap it behind one interface:

// src/auth/store.ts
import { SecureStorage } from '@aparajita/capacitor-secure-storage';

const KEY = 'auth.tokens';

export async function saveTokens(t: TokenSet): Promise<void> {
  await SecureStorage.set(KEY, JSON.stringify({
    ...t,
    expiresAt: Date.now() + t.expires_in * 1000,
  }));
}

export async function loadTokens(): Promise<StoredTokens | null> {
  const raw = await SecureStorage.get(KEY);
  return raw ? JSON.parse(raw as string) : null;
}

export async function clearTokens(): Promise<void> {
  await SecureStorage.remove(KEY);
}

If the customer's policy requires it, gate loadTokens() behind a biometric prompt on resume. Keep the prompt on resume after N minutes, not on every foreground, or usage drops off a cliff.

6. Refresh without a thundering herd

One in-flight refresh, shared by every caller, with the rotated refresh token written back:

// src/auth/session.ts
let inFlight: Promise<StoredTokens> | null = null;

export async function accessToken(): Promise<string> {
  const t = await loadTokens();
  if (!t) throw new NotAuthenticated();
  if (t.expiresAt - Date.now() > 60_000) return t.access_token;

  inFlight ??= refresh(t.refresh_token).finally(() => { inFlight = null; });
  return (await inFlight).access_token;
}

async function refresh(refreshToken: string): Promise<StoredTokens> {
  const eps = await discover(authConfig.issuer);
  const res = await fetch(eps.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: authConfig.clientId,
    }),
  });
  if (res.status === 400) {          // revoked, rotated twice, or policy change
    await clearTokens();
    throw new NotAuthenticated();
  }
  const next = await res.json();
  await saveTokens(next);            // rotation: always persist the NEW refresh token
  return (await loadTokens())!;
}

Then one interceptor so no screen ever thinks about tokens. Angular:

export const authInterceptor: HttpInterceptorFn = (req, next) =>
  from(accessToken()).pipe(
    switchMap((token) =>
      next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })),
    ),
    catchError((err) => {
      if (err instanceof NotAuthenticated) router.navigate(['/login']);
      return throwError(() => err);
    }),
  );

Two rules we hold to in every engagement: never send the access token to anything but the audience it was issued for, and never read the access token in the app to make authorization decisions. Scopes and roles are enforced server-side; the app may read the ID token to decide what to show, which is a UI nicety, not security.

7. Logout that actually logs out

Clearing local storage logs the app out and nothing else — tap sign-in again and the browser session signs the user straight back in, which reads as a bug. Do all three:

export async function logout(): Promise<void> {
  const t = await loadTokens();
  const eps = await discover(authConfig.issuer);

  if (t?.refresh_token && eps.revocationEndpoint) {
    await fetch(eps.revocationEndpoint, {           // 1. revoke server-side
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        token: t.refresh_token,
        client_id: authConfig.clientId,
      }),
    }).catch(() => {});
  }

  await clearTokens();                              // 2. drop local state

  if (eps.endSessionEndpoint) {                     // 3. end the IdP session
    const url = new URL(eps.endSessionEndpoint);
    url.searchParams.set('id_token_hint', t?.id_token ?? '');
    url.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/auth/logout');
    await Browser.open({ url: url.toString() });
  }
}

Also handle logout you did not initiate: a 401 with WWW-Authenticate: Bearer error="invalid_token" after a successful refresh attempt means the session was killed centrally. Wipe local state, push the user to the login screen, and clear any cached SQLite data that belongs to that user (see our offline-first tutorial — an offline cache that outlives a revoked session is a finding, not a feature).

8. Multi-tenant: one binary, many customers

The pattern that has held up best for our clients:

  1. Ask for an email or an org slug on the first screen.
  2. Resolve it to an issuer via your own endpoint (GET /api/tenants/lookup?domain=acme.com{ issuer, clientId }).
  3. Run discovery against that issuer and proceed exactly as above.
  4. Cache the resolved tenant so returning users skip step 1.

Keep an allowlist of acceptable issuers server-side. Accepting an arbitrary issuer from client input is how you end up trusting tokens minted by an IdP the attacker controls.

Testing checklist

  • Cancel the browser mid-login → app returns to the login screen, no stuck spinner.
  • Kill the app while the browser is open, then complete login → succeeds (this is the persisted-verifier test).
  • Expired access token + valid refresh → one refresh call, not twenty (fire ten parallel requests and count).
  • Refresh token revoked in the IdP admin console → next call lands on the login screen, cache cleared.
  • Conditional access requiring MFA → step-up prompt shows in the system browser and completes.
  • Airplane mode at launch with a valid cached session → app opens read-only rather than bouncing to login.
  • Second device, same account → both sessions work; revoking one does not kill the other.

Automate the first four in your Playwright/Maestro suite against a test tenant with a seeded service account; the rest are release-candidate manual passes.

Where teams get stuck

Almost every enterprise SSO rollout we are called into is stuck on one of four things: credentials collected in a WebView (rejected by the IdP or by review), a refresh token in localStorage flagged in a penetration test, rotation implemented without persisting the new token (users silently logged out after an hour), or a logout that leaves the browser session alive.

If you are wiring Entra ID, Okta or Keycloak into an Ionic app and want the flow reviewed before your customer's security team reviews it, our senior Ionic consultants do exactly this work — get in touch.