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:
- Stop storing secrets in the WebView. Keychain on iOS, Keystore on Android.
- Attest the app and device with Apple App Attest and Google Play Integrity.
- Pin TLS so a proxy on the device cannot read or rewrite traffic.
- 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:
| Endpoint | Why it matters |
|---|---|
POST /auth/register, /auth/login | fake account creation, credential stuffing |
POST /orders, /payments/intent | card testing, price tampering |
POST /promos/redeem, /referrals | bonus 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 storageandroid:allowBackup="false"or exclude the file indata_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:
- 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.
- 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. - 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
-
grepthe builtwww/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,
requestHashbound. - 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.