+1 (415) 843-4662

Hardening Ionic apps: App Attest, Play Integrity, secure token storage and TLS pinning

Every Ionic app we audit has the same hole. The backend trusts a bearer token, the bearer token lives in localStorage, and nothing on the server can tell whether a request came from your app on a real device or from a script someone wrote after ten minutes with a proxy. For a content app that is fine. For anything with payments, coupons, quotas, referral bonuses, exam content or regulated data, it is the whole attack surface.

This tutorial closes it in four layers, in the order we deploy them:

  1. Stop storing secrets in the WebView. Keychain on iOS, Keystore on Android.
  2. Attest the app and device with Apple App Attest and Google Play Integrity.
  3. Pin TLS so a proxy on the device cannot read or rewrite traffic.
  4. Fail closed carefully — a monitor phase, then enforcement, with a kill switch.

Examples are Capacitor 7/8 with an Ionic Angular or React front end; the JavaScript is framework-agnostic and the native snippets are Swift and Kotlin.

Layer 0: know what you are defending

Attestation proves the request came from an unmodified build of your app on a genuine device. It does not prove the user is honest, and it does not protect against a real user doing something stupid in your real app. Before you write any code, list the endpoints that actually need it. In practice it is a short list:

EndpointWhy it matters
POST /auth/register, /auth/loginfake account creation, credential stuffing
POST /orders, /payments/intentcard testing, price tampering
POST /promos/redeem, /referralsbonus farming
GET /content/{premium}scraping paid content

Everything else can stay unattested. Attestation costs a round trip and a small failure rate; spending it on GET /health buys nothing.

Layer 1: get tokens out of localStorage

Anything in localStorage, sessionStorage, IndexedDB or Capacitor Preferences is plaintext inside the app sandbox. On a rooted or jailbroken device — and in an iOS backup, and in some MDM extraction tools — it is readable. Refresh tokens belong in the platform secure store.

npm i capacitor-secure-storage-plugin
npx cap sync
// src/app/core/token-store.ts
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
import { Capacitor } from '@capacitor/core';

const KEY = 'refresh_token';

// On the web there is no secure store. Keep the refresh token in an
// httpOnly cookie set by the API instead, and never mirror it into JS.
const native = Capacitor.isNativePlatform();

export const tokenStore = {
  async setRefreshToken(value: string) {
    if (!native) return;                       // web: cookie handles it
    await SecureStoragePlugin.set({ key: KEY, value });
  },
  async getRefreshToken(): Promise<string | null> {
    if (!native) return null;
    try {
      const { value } = await SecureStoragePlugin.get({ key: KEY });
      return value;
    } catch {
      return null;                             // not set, or keystore reset
    }
  },
  async clear() {
    if (!native) return;
    try { await SecureStoragePlugin.remove({ key: KEY }); } catch { /* noop */ }
  },
};

Three rules that matter more than the plugin choice:

  • Access tokens stay in memory only. Short-lived (5–15 minutes), never persisted. If the app is killed, you re-mint from the refresh token.
  • Refresh tokens rotate. Each refresh returns a new one and invalidates the old. If an old one is ever replayed, revoke the whole family — that is your strongest signal that a token was stolen.
  • Exclude from backup. On iOS use a Keychain accessibility class of kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; on Android mark your storage android:allowBackup="false" or exclude the file in data_extraction_rules.xml.

While you are in there, delete any API key you bundled into the JS. grep -rE "(sk_live|AIza|AKIA|Bearer )" www/ on your built output. Anything it finds is public, because www/ ships inside the IPA and APK in clear text.

Layer 2: Apple App Attest

App Attest gives you a hardware-backed key pair, created in the Secure Enclave, that Apple's servers vouch for. The flow has two phases: attest once per install, then assert on each protected request.

There is no first-party Capacitor plugin, and this is a good candidate for a small custom plugin (see our post on writing a Capacitor plugin in Swift and Kotlin). The Swift side:

// ios/App/App/AttestPlugin.swift
import Capacitor
import DeviceCheck
import CryptoKit

@objc(AttestPlugin)
public class AttestPlugin: CAPPlugin {
  private let service = DCAppAttestService.shared

  @objc func generateKey(_ call: CAPPluginCall) {
    guard service.isSupported else {
      // Simulators and some older devices: report, do not crash.
      call.reject("unsupported"); return
    }
    service.generateKey { keyId, error in
      if let keyId { call.resolve(["keyId": keyId]) }
      else { call.reject(error?.localizedDescription ?? "generateKey failed") }
    }
  }

  @objc func attest(_ call: CAPPluginCall) {
    guard let keyId = call.getString("keyId"),
          let challenge = call.getString("challenge") else {
      call.reject("keyId and challenge required"); return
    }
    let hash = Data(SHA256.hash(data: Data(challenge.utf8)))
    service.attestKey(keyId, clientDataHash: hash) { attestation, error in
      if let attestation {
        call.resolve(["attestation": attestation.base64EncodedString()])
      } else { call.reject(error?.localizedDescription ?? "attest failed") }
    }
  }

  @objc func assert(_ call: CAPPluginCall) {
    guard let keyId = call.getString("keyId"),
          let clientData = call.getString("clientData") else {
      call.reject("keyId and clientData required"); return
    }
    let hash = Data(SHA256.hash(data: Data(clientData.utf8)))
    service.generateAssertion(keyId, clientDataHash: hash) { assertion, error in
      if let assertion {
        call.resolve(["assertion": assertion.base64EncodedString()])
      } else { call.reject(error?.localizedDescription ?? "assert failed") }
    }
  }
}

Add the App Attest capability in Xcode (com.apple.developer.devicecheck.appattest-environment, production for App Store and TestFlight builds, development for local runs) — a mismatch here is the single most common cause of "it works on my Mac, it fails on TestFlight".

Server side, the challenge must come from you and must be single-use:

// POST /attest/challenge
app.post('/attest/challenge', requireAuth, async (req, res) => {
  const challenge = crypto.randomBytes(32).toString('base64url');
  await redis.set(`chal:${challenge}`, req.auth.userId, { EX: 120, NX: true });
  res.json({ challenge });
});

// POST /attest/register  { keyId, attestation, challenge }
app.post('/attest/register', requireAuth, async (req, res) => {
  const { keyId, attestation, challenge } = req.body;
  const owner = await redis.getDel(`chal:${challenge}`);   // single use
  if (owner !== req.auth.userId) return res.status(400).json({ error: 'bad_challenge' });

  // Verify the attestation object against Apple's root CA, check the
  // nonce, the app ID hash, the counter and the environment. Use a
  // maintained library rather than hand-rolling CBOR/X.509 parsing.
  const { publicKey, receipt } = await verifyAppAttestAttestation({
    keyId, attestation, challenge,
    appId: `${TEAM_ID}.${BUNDLE_ID}`,
    production: true,
  });

  await db.attestKeys.insert({ userId: req.auth.userId, keyId, publicKey, receipt, counter: 0 });
  res.json({ ok: true });
});

On each protected call the app sends an assertion over a canonical clientData string — method, path, a body hash and a fresh challenge — and the server verifies the signature with the stored public key and checks that the assertion counter strictly increases. A counter that goes backwards means replay.

Layer 3: Google Play Integrity

Android's equivalent is the Play Integrity API. Use the standard request flow: the token is minted quickly from a warm provider and you decrypt the verdict server-side through the Play Integrity endpoint.

// android/app/src/main/java/.../IntegrityPlugin.kt
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import com.google.android.play.core.integrity.*
import java.security.MessageDigest

@CapacitorPlugin(name = "Integrity")
class IntegrityPlugin : Plugin() {
  private var provider: StandardIntegrityManager.StandardIntegrityTokenProvider? = null

  @PluginMethod
  fun prepare(call: PluginCall) {
    val manager = IntegrityManagerFactory.createStandard(context)
    manager.prepareIntegrityToken(
      StandardIntegrityManager.PrepareIntegrityTokenRequest.builder()
        .setCloudProjectNumber(CLOUD_PROJECT_NUMBER)
        .build()
    ).addOnSuccessListener { p -> provider = p; call.resolve() }
     .addOnFailureListener { e -> call.reject("prepare failed: ${e.message}") }
  }

  @PluginMethod
  fun token(call: PluginCall) {
    val p = provider ?: run { call.reject("not_prepared"); return }
    val requestHash = sha256(call.getString("clientData") ?: "")
    p.request(
      StandardIntegrityManager.StandardIntegrityTokenRequest.builder()
        .setRequestHash(requestHash)
        .build()
    ).addOnSuccessListener { r -> call.resolve(JSObject().put("token", r.token())) }
     .addOnFailureListener { e -> call.reject("token failed: ${e.message}") }
  }

  private fun sha256(s: String) =
    MessageDigest.getInstance("SHA-256").digest(s.toByteArray())
      .joinToString("") { "%02x".format(it) }
}

Call prepare() once at startup (it is the slow part) and token() per protected request, always with a requestHash bound to that request. Server side you decrypt and read the verdict:

const verdict = await playIntegrity.decodeIntegrityToken(token);
const { appIntegrity, deviceIntegrity, accountDetails, requestDetails } = verdict.tokenPayloadExternal;

if (requestDetails.requestHash !== expectedHash) throw new Forbidden('hash_mismatch');
if (Date.now() - Number(requestDetails.timestampMillis) > 60_000) throw new Forbidden('stale');
if (appIntegrity.appRecognitionVerdict !== 'PLAY_RECOGNIZED') throw new Forbidden('unrecognized_app');

const labels = deviceIntegrity.deviceRecognitionVerdict ?? [];
const trusted = labels.includes('MEETS_DEVICE_INTEGRITY');

Decide deliberately how strict to be. MEETS_DEVICE_INTEGRITY alone is the usual bar. Requiring MEETS_STRONG_INTEGRITY will lock out a meaningful slice of legitimate users on older or custom-ROM devices. And note the emulator reality: your own CI and QA devices will fail these checks, so route them through an allowlist rather than weakening the rule for everyone.

A shared client wrapper

One HTTP interceptor keeps this out of your feature code:

// src/app/core/attested-fetch.ts
import { Capacitor } from '@capacitor/core';
import { Attest, Integrity } from './plugins';

const PROTECTED = [/^\/orders/, /^\/payments/, /^\/promos/, /^\/auth\/register/];

export async function attestedFetch(path: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers);

  if (PROTECTED.some((re) => re.test(path))) {
    const bodyHash = await sha256Hex(typeof init.body === 'string' ? init.body : '');
    const { challenge } = await fetch('/api/attest/challenge').then((r) => r.json());
    const clientData = JSON.stringify({ m: init.method ?? 'GET', p: path, b: bodyHash, c: challenge });

    try {
      if (Capacitor.getPlatform() === 'ios') {
        const keyId = await ensureAttestedKey();                       // cached after first run
        const { assertion } = await Attest.assert({ keyId, clientData });
        headers.set('X-Attest-KeyId', keyId);
        headers.set('X-Attest-Assertion', assertion);
      } else if (Capacitor.getPlatform() === 'android') {
        const { token } = await Integrity.token({ clientData });
        headers.set('X-Integrity-Token', token);
      }
      headers.set('X-Client-Data', clientData);
    } catch (e) {
      // Attestation unavailable (simulator, Play Services missing, offline).
      // Send the request unsigned and let the server's policy decide.
      headers.set('X-Attest-Error', String((e as Error).message).slice(0, 120));
    }
  }

  return fetch(path, { ...init, headers });
}

Note the catch. The client never decides policy — it reports what happened and the server chooses. That is what makes a staged rollout possible.

Layer 4: TLS pinning

Attestation stops fake clients. Pinning stops a real client's traffic being read through a proxy. On Android, use network security config — no code:

<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
  <domain-config cleartextTrafficPermitted="false">
    <domain includeSubdomains="true">api.example.com</domain>
    <pin-set expiration="2027-06-01">
      <pin digest="SHA-256">base64OfCurrentLeafOrIntermediateSPKI=</pin>
      <pin digest="SHA-256">base64OfBackupSPKI=</pin>
    </pin-set>
  </domain-config>
</network-security-config>

On iOS the equivalent lives in a URLSessionDelegate, which means pinned calls must go through a native HTTP plugin rather than the WebView's fetch.

Pin the SPKI, not the certificate, and always ship a backup pin for a key you have generated but not yet deployed. Set an expiration. A pinned app whose certificate rotated without a matching release is a self-inflicted outage that only an app-store update can fix — which is exactly why some teams pin only the payment endpoints, or skip pinning and rely on attestation plus short token lifetimes.

Rolling it out without breaking Monday morning

We have never seen a hard cutover go well. Use three phases:

  1. Monitor (2–4 weeks). Ship the client, send the headers, verify on the server, and log only. Chart pass rate by platform, OS version and app version. Real-world baselines are usually 95–99% on iOS and 90–97% on Android; anything below that is your bug, not attackers.
  2. Enforce on the narrow list. Turn on rejection for the money endpoints only, behind a server-side flag you can flip per endpoint, per platform and per app version. Return a specific error (403 attestation_required) that the app maps to "please update the app", never a blank spinner.
  3. Widen slowly, watching support tickets. Keep an escape hatch: an allowlist for QA device IDs, and a global off switch that does not require a deploy.

Budget for the tail. Devices without Play Services, corporate MDM proxies, re-signed enterprise builds and Chinese OEM ROMs will fail legitimately. Decide in advance whether those users get a degraded flow (web checkout, say) or a support path.

What this does not do

  • It is not obfuscation. Anyone can still unzip your IPA and read www/. Move logic that must stay secret to the server; that is the only real fix.
  • It is not authorisation. Attestation says "real app"; it says nothing about whether this user may read that record. Keep your server-side checks.
  • It is not permanent. Verdicts change as Apple and Google update their platforms. Treat pass rate as a monitored metric with an alert, like crash rate.

A checklist you can run this week

  • grep the built www/ for keys and secrets; remove every hit.
  • Refresh tokens in Keychain/Keystore, access tokens in memory, rotation on.
  • allowBackup=false / device-only Keychain accessibility.
  • List of endpoints that need attestation, written down and agreed.
  • App Attest capability set, environment correct for TestFlight.
  • Play Integrity standard requests prepared at startup, requestHash bound.
  • Server verifies challenge single-use, counter increasing, hash matching.
  • Monitor-only phase live with a dashboard before anything is enforced.
  • Backup TLS pin and an expiry date in the calendar.

If you want a second pair of eyes on any of this, HybridMob's senior Ionic consultants do fixed-scope hardening reviews of Ionic and Capacitor apps — threat model, findings ranked by exploitability, and the pull requests to fix them. Get in touch and tell us what your app protects.