Mobile App Security

Secure mobile applications against common attack vectors. Covers certificate pinning, secure storage, biometric authentication, code obfuscation, anti-tampering, and the patterns that protect sensitive data on devices you don't control.

Mobile applications must protect user data, enforce access control, and resist attacks across networks, devices, and lifecycles. Mobile App Security is the discipline of ensuring that app code, data, and communication remain confidential, intact, and available from the moment a user installs the app to the final tap of a transaction. It matters at every stage: during development, release, and especially in production, where users expose credentials, payment details, and private content through untrusted networks.

Securing the App Binary and Update Process

Mobile apps are shipped as binaries: APKs for Android, IPA files for iOS. These binaries must be hardened against reverse engineering, tampering, and code injection. The primary defense starts with build-time configuration.

Android apps are built with Gradle. To enable code shrinking and obfuscation, include the following in app/build.gradle:

android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

The proguard-rules.pro file defines which classes and methods should be preserved. For example, to prevent deserialization failures in a JSON API client:

-keep class com.example.app.api.** { *; }
-keep class com.example.app.model.** { *; }
-keepclassmembers class * implements android.os.Parcelable {
    static final android.os.Parcelable$Creator CREATOR;
}

iOS apps use Xcode’s build system. To enable code stripping and bitcode, set the following in the app target:

During build, xcodebuild generates a .ipa file. To verify the integrity of the built app, compute its SHA-256 hash and embed it in the app’s Info.plist:

<key>CFBundleVersion</key>
<string>1.2.3</string>
<key>AppSecurity</key>
<dict>
    <key>BuildHash</key>
    <string>9b3e5a1d2c4f7e8c0a1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3</string>
</dict>

To verify this hash at runtime, the app checks the hash stored at app launch:

fun verifyAppIntegrity(context: Context): Boolean {
    val appInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_META_DATA)
    val expectedHash = appInfo.applicationInfo.metaData.getString("AppSecurity.BuildHash")
    val actualHash = computeSha256(context.assets.open("app.ipa"))
    return expectedHash == actualHash
}

The app can also validate the app’s signature using the platform’s certificate store. On iOS, use SecTrustEvaluate from Security framework; on Android, use CertificateFactory and X509Certificate.

Protecting Data at Rest and in Transit

Data in mobile apps lives in three states: at rest (on device), in transit (over network), and in memory (in RAM). Security begins with securing the data in each state.

For data at rest, apps use encrypted storage. Android apps store data in SharedPreferences, SQLite, and Room. The Room persistence library supports encryption out of the box via SQLCipher.

To enable encryption in Room, configure the database:

val db = Room.databaseBuilder(context, AppDatabase::class.java, "mydb")
    .addMigrations(MIGRATION_1_2)
    .openHelperFactory(
        EncryptedRoomOpenHelperFactory(
            "my-secret-key",
            "my-encryption-key",
            true
        )
    )
    .build()

The EncryptedRoomOpenHelperFactory uses SQLCipher under the hood, which requires a key derived from a passphrase. The key is stored in Android’s KeyStore and protected by the device’s hardware-backed keystore.

On iOS, CoreData can be configured with NSPersistentContainer and an encrypted store:

let container = NSPersistentContainer(name: "MyModel")
container.persistentStoreDescriptions.first?.setOption(
    NSNumber(value: true),
    forKey: NSPersistentContainer.EncryptedStoreOptionKey
)
container.persistentStoreDescriptions.first?.setOption(
    "my-secret-key",
    forKey: NSPersistentContainer.KeyStoreOptionKey
)

Data in transit is secured using HTTPS. The app must verify the server’s certificate and enforce strict validation.

For Android, configure OkHttp with a CertificatePinner:

val client = OkHttpClient.Builder()
    .certificatePinner(
        CertificatePinner.Builder()
            .add("api.example.com", "sha256/ABC123...")
            .add("api.example.com", "sha256/DEF456...")
            .build()
    )
    .build()

The sha256/ values are the pinning hashes of the server’s certificate chain. The app can also enforce certificate transparency (CT) by validating CT logs.

On iOS, use URLSession with NSURLSessionConfiguration and ServerTrustEvaluation:

let configuration = URLSessionConfiguration.default
let evaluator = CustomServerTrustEvaluator()
configuration.urlCache = URLCache.shared
configuration.httpAdditionalHeaders = [
    "User-Agent": "MyApp/1.2.3 (iOS; iPhone)"
]

let session = Foundation.URLSession(configuration: configuration, delegate: evaluator, delegateQueue: nil)

The CustomServerTrustEvaluator implements URLSessionDelegate and validates the server’s certificate chain, checks for certificate pinning, and optionally enforces CT.

Securing Authentication and Session Management

Authentication is the first line of defense. Mobile apps use tokens (JWT, OAuth2), passwords, and biometrics.

For JWT-based authentication, the app must validate the token’s signature, expiration, and claims.

A typical JWT is structured as:

{
  "alg": "HS256",
  "typ": "JWT"
}
.
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516239022,
  "roles": ["user", "admin"]
}
.
{ /* signature */ }

The app must validate:

To validate the signature on Android, use JWTParser from com.auth0.java-jwt:

val jwt = JWT.decode(tokenString)
val verifier = Algorithm.HMAC256("shared-secret")
val jwtVerifier = JWTVerifier.Builder(verifier).withIssuer("https://auth.example.com").build()

try {
    jwtVerifier.verify(jwt)
    // Token is valid
} catch (e: JWTVerificationException) {
    // Handle invalid token: expired, invalid signature, unknown issuer
}

Session management includes token refresh and persistence. The app stores the token in a secure, persistent store.

On Android, use SharedPreferences with EncryptedSharedPreferences:

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val encryptedPrefs = EncryptedSharedPreferences.create(
    context,
    "auth_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

On iOS, use Keychain to store the token:

let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "auth_token",
    kSecAttrService as String: "myapp",
    kSecValueData as String: token.data(using: .utf8)!
]

SecItemAdd(query as CFDictionary, nil)

The app must also implement token refresh. When the token is about to expire (e.g., within 5 minutes), the app fetches a new token from the server in the background.

fun scheduleTokenRefresh(context: Context) {
    val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
    val intent = Intent(context, TokenRefreshReceiver::class.java)
    val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)

    val now = System.currentTimeMillis()
    val refreshTime = now + (5 * 60 * 1000) // 5 minutes from now

    alarmManager.setExact(AlarmManager.RTC_WAKEUP, refreshTime, pendingIntent)
}

Defending Against Dynamic Analysis and Tampering

Mobile apps are vulnerable to tampering and runtime analysis. Attackers use tools like Frida, Objection, and Jadx to inspect and modify app behavior.

To counter this, apps implement runtime integrity checks.

The app can detect if it is running in a debugger or emulator. On Android, check Build.FINGERPRINT and Build.PRODUCT:

fun isDebuggerAttached(): Boolean {
    return Debug.isDebuggerConnected() || Debug.isDebuggerActive()
}

fun isEmulator(): Boolean {
    return Build.FINGERPRINT.contains("generic") ||
           Build.FINGERPRINT.contains("sdk") ||
           Build.BOARD.contains("goldfish") ||
           Build.MODEL.contains("Android SDK")
}

On iOS, use ProcessInfo and sysctl:

func isDebuggerAttached() -> Bool {
    var info = utsname()
    uname(&info)
    let hostname = String(cString: info.nodename)
    return hostname == "localhost" || hostname.contains("emulator")
}

The app can also use code introspection to verify that critical functions have not been modified.

With Frida, the app can hook into malloc, free, and strlen to detect tampering:

// frida-hook.js
Java.perform(function () {
    var malloc = Module.findExportByName(null, "malloc");
    var free = Module.findExportByName(null, "free");
    var strlen = Module.findExportByName(null, "strlen");

    Interceptor.replace(malloc, new NativeCallback(function (size) {
        console.log("malloc(" + size + ")");
        return malloc(size);
    }, "pointer", ["uint32"]));

    Interceptor.replace(free, new NativeCallback(function (ptr) {
        console.log("free(" + ptr + ")");
        free(ptr);
    }, "void", ["pointer"]));
});

The app can bundle this script with the app and load it on startup.

Additionally, the app can verify its own code integrity. It computes a hash of the app’s main executable (.so, .dylib, classes.dex) and compares it to a known good hash.

fun verifyBinaryIntegrity(context: Context): Boolean {
    val bytes = context.assets.open("libmain.so").readBytes()
    val hash = computeSha256(bytes)
    val expected = context.resources.getString(R.string.app_binary_hash)
    return hash == expected
}

If the hash does not match, the app logs an error and may trigger a full security scan or prompt the user to reinstall the app.

Responding to Security Events and Failures

Security is not passive. The app must detect, report, and respond to security events.

When a token is invalidated, the app must clear the session and re-authenticate.

When a certificate pinning failure occurs, the app logs the expected and actual certificate chains:

{
  "event": "certificate_pinning_failure",
  "expected": [
    "sha256/ABC123...",
    "sha256/DEF456..."
  ],
  "actual": [
    "sha256/XYZ789...",
    "sha256/ABC123..."
  ],
  "hostname": "api.example.com",
  "timestamp": "2023-04-15T12:34:56Z"
}

The app can also monitor for unexpected behaviors: unauthorized access, slow response times, unexpected network calls.

To detect anomalies, the app uses a simple event-driven model:

class SecurityMonitor {
    private val events = mutableListOf<SecurityEvent>()

    fun record(event: SecurityEvent) {
        events.add(event)
        if (event.type == SecurityEventType.BRUTE_FORCE_ATTEMPT) {
            if (events.count { it.type == SecurityEventType.BRUTE_FORCE_ATTEMPT } >= 5) {
                triggerSecurityAlert("High-frequency login attempts detected")
            }
        }
    }

    fun triggerSecurityAlert(message: String) {
        // Send to analytics, push notification, log to file
        Log.e("Security", message)
        sendToServer("/api/security/alert", mapOf("message" to message))
    }
}

The app can also define a security policy in a JSON configuration file:

{
  "token": {
    "expiration_threshold": "5m",
    "refresh_interval": "15m",
    "require_refresh_on_background": true
  },
  "network": {
    "require_https": true,
    "pinning": [
      {
        "hostname": "api.example.com",
        "certificates": [
          "sha256/ABC123...",
          "sha256/DEF456..."
        ]
      }
    ]
  },
  "runtime": {
    "detect_debugger": true,
    "detect_emulator": true,
    "verify_binary": true
  }
}

This policy is loaded at app startup and guides the app’s security behavior.

Summary

Mobile App Security is the continuous effort to protect user data and app integrity across all layers: binary, storage, network, and runtime. The app must validate its own integrity, secure data at rest and in transit, manage authentication and sessions with care, and respond dynamically to threats. The foundation is a set of concrete, reusable patterns—verifying hashes, pinning certificates, checking for debuggers, refreshing tokens—that are implemented with real tools and configurations. Failure is silent: a missing key, a misconfigured trust evaluator, a forgotten if condition. The reader must know how to build, verify, and debug each of these patterns.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.