A Client You Cannot Control#

A mobile app runs on a device you do not own and cannot trust. The user (or an attacker who has the user's phone) can root or jailbreak it, decompile the app, hook its functions at runtime with instrumentation frameworks, and read or modify everything it does. Mobile application security starts from that reality: treat the client as hostile territory, and design the backend so that a fully compromised client yields nothing the server was supposed to protect.

The One Rule: Never Trust the Client#

Anything the app enforces on the device, licensing, feature gates, price calculations, access control, can be bypassed on a compromised phone. Therefore every security decision that matters must be re-made on the server. The app is a presentation layer; the server is the source of truth. A "premium" flag flipped in the app is meaningless if the server checks entitlement independently; a discount applied in the app is meaningless if the server recomputes the price. This is the same principle as business logic flaws, sharpened by the fact that the client is even more exposed than a browser.

The OWASP MASVS (Mobile Application Security Verification Standard) organises the concerns into domains worth knowing: storage, cryptography, authentication, network communication, platform interaction, code quality, and resilience (anti-tampering). MASTG is the companion testing guide.

Insecure Data Storage#

The most common mobile finding is sensitive data left where another app, a backup or a forensic tool can read it:

  • Tokens, passwords or PII in SharedPreferences (Android) or NSUserDefaults (iOS), which are plaintext files.
  • Data in an unencrypted SQLite database, or written to external/shared storage.
  • Secrets in log output, which persists and may be readable by other apps or in bug reports.
  • Credentials that survive uninstall via cloud backup.

Store secrets in the platform's hardware-backed secure storage instead:

// Android: store a token in EncryptedSharedPreferences, keyed in the Keystore
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val prefs = EncryptedSharedPreferences.create(
    context, "auth", masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit().putString("refresh_token", token).apply()

The key material lives in the Android Keystore (hardware-backed on modern devices) and never leaves it, so even a rooted device cannot trivially extract it. The iOS equivalent is the Keychain with an appropriate accessibility class such as kSecAttrAccessibleWhenUnlockedThisDeviceOnly, which also keeps the item out of backups. Also exclude sensitive files from backups, and never log secrets.

Transport Security and Pinning#

All traffic must use TLS with certificate validation enabled. A frequent, severe mistake is disabling validation for a test environment and shipping that configuration, which makes interception trivial. On Android, use a strict Network Security Config; never override TrustManager to accept all certificates.

Certificate (or public-key) pinning binds the app to a specific certificate or key, so an attacker who installs a rogue CA on the device (the standard way to intercept mobile traffic with a proxy) still cannot read the traffic. The trade-off is operational: a pin that expires or a certificate rotation you forgot about will brick the app for users until they update. Mitigate by pinning to the public key rather than the leaf certificate, including backup pins for the next key, and having a rotation plan. Pinning raises the cost of interception; it does not replace server-side controls.

Authentication and Session Management#

Mobile session handling has its own pitfalls: tokens stored insecurely (see above), refresh tokens with excessive lifetimes, no server-side revocation, and "remember me" that never expires. Guidance:

  • Short-lived access tokens plus a refresh token held in secure storage.
  • Server-side session revocation on logout and the ability to revoke remotely (a lost-phone button).
  • Bind tokens to the device where possible, so a stolen token is less portable.
  • Do not implement your own crypto or token format; use platform-vetted libraries.

Reverse Engineering and Client-Side Checks#

Attackers decompile apps (with tools like jadx and apktool for Android, class-dump and Hopper for iOS) to find hardcoded API keys, hidden endpoints and business logic. An API key compiled into the app is not secret; assume anything in the binary is public. Obfuscation (R8/ProGuard, string encryption) raises the effort but does not stop a determined analyst.

Root/jailbreak and tampering detection and anti-debugging are worth adding, but only as telemetry and friction, never as a security guarantee, because every client-side check can be patched out or hooked with Frida:

// Frida: neutralise a client-side jailbreak check by forcing it to return false
Java.perform(function () {
    var Sec = Java.use("com.example.app.SecurityUtils");
    Sec.isRooted.implementation = function () {
        return false;   // the app now believes the device is clean
    };
});

That five-line hook defeats a naive local check entirely, which is exactly why the decision it feeds must not live on the client. Use the signal to raise the account's risk score server-side, not to unlock features locally.

Server-Side Defences (Where the Real Controls Live)#

Because the client is untrusted, put the enforceable controls on the server:

  • App and device attestation: Google Play Integrity (Android) and Apple App Attest / DeviceCheck (iOS) let your server verify that a request came from a genuine, unmodified build of your app on a genuine device. The verdict is checked server-side, so it cannot be patched away on the client.
  • Authentication and rate limiting enforced by the API independently of the app.
  • Server-side validation of every input and every business rule, recomputing prices, entitlements and totals from authoritative data.
  • Authorisation on every object access to prevent one user reading another's data (IDOR).

Platform Interaction Pitfalls#

Beyond storage and network, several device-integration mistakes recur:

  • Exported components (Android). An Activity, Service, BroadcastReceiver or ContentProvider marked android:exported="true" (or exported implicitly by declaring an intent filter) can be invoked by any other app on the device. An exported component that performs a privileged action, or a ContentProvider that exposes internal data without permission checks, is a local privilege or data-leak bug. Export only what genuinely needs to be, and enforce permissions on what you do export.
  • Insecure deep links and custom URL schemes. A myapp:// link or an App/Universal Link that carries a token or triggers an action can be forged by another app or a web page. Validate and authorise everything a link asks for, and prefer verified App Links / Universal Links over unclaimed custom schemes.
  • WebViews. Enabling JavaScript and, worse, a JavaScript-to-native bridge (addJavascriptInterface) inside a WebView that loads remote or attacker-influenced content can hand page scripts access to native functions. Load only trusted content, disable file access, and keep any bridge tightly scoped.
  • Clipboard and screenshots. Sensitive fields copied to the shared clipboard are readable by other apps; flag sensitive screens to be excluded from the recent-apps thumbnail and screenshots.

Each of these is a client-side surface, so the same rule holds: an exported component or deep link may trigger a request, but the server still authorises and validates it.

Testing Mobile Apps#

Assess with both static and dynamic analysis, on a rooted or jailbroken device because that is the attacker's environment:

  • Static: decompile (jadx, apktool), scan for hardcoded secrets and endpoints, review storage and TLS configuration. MobSF automates much of this.
  • Dynamic: intercept traffic with mitmproxy (installing a CA and, where pinning blocks you, using a Frida script to bypass it on your own build), and hook functions with Frida/Objection to observe and manipulate runtime behaviour.
  • Check storage after use: pull the app's data directory and confirm no plaintext tokens or PII remain.

The goal is not an unbreakable app, which is impossible. It is to ensure that breaking the client yields no data or capability the server is responsible for protecting. Traffic interception tooling is shared with mitmproxy and HAR.

Practice#

On a test build of an app you own, store a token in plain SharedPreferences/NSUserDefaults, then pull the app data directory and confirm you can read it. Switch to EncryptedSharedPreferences/Keychain and confirm it is now unreadable. Next, add a trivial root check, bypass it with the Frida snippet above, and then move the actual gating decision to a server endpoint that checks Play Integrity or App Attest, observing that the Frida bypass no longer unlocks anything.