Mobile Security (Cross-platform)
Irbisa · cheatsheetSeptember 13, 2026

Mobile Security (Cross-platform)

Keychain/Keystore, secure storage, certificate pinning, jailbreak/root, App Transport Security

Senior Developer20 itemscompressed for a skim
  1. 01

    What is the OWASP Mobile Top 10 and what should every mobile dev know about it?

    Medium

    The OWASP Mobile Top 10 names the ten risk categories that most often break real mobile apps, and the 2024 revision is the current one.

    // M9 — encrypt with a Keystore-held key yourself. androidx.security-crypto
    // (EncryptedSharedPreferences) is deprecated; do not start new code on it.
    fun secretKey(alias: String): SecretKey {
      val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
      (ks.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
      val spec = KeyGenParameterSpec.Builder(
    …
  2. 02

    What does it take to make mobile API traffic hard to intercept and hard to replay?

    Medium

    No single control protects mobile API traffic, so you layer transport security, token hygiene and server-side attestation on the assumption that every client-side control will eventually be patched out.

    // Android — OkHttp SPKI pinning, with a pre-published backup pin for rotation
    val pinner = CertificatePinner.Builder()
      .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")   // current
      .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")   // next
      .build()
    …
  3. 03

    Client-side root and jailbreak checks can be hooked out in minutes. What do you rely on instead, and what is a client-side check still worth?

    Medium

    Treat the client's answer as a signal and the server's attestation verdict as the decision — a rooted device can lie about being rooted, so anything enforced only on the client is enforced nowhere.

    // Client-side heuristics — one signal, never the decision
    fun localRootSignal(context: Context): Boolean = RootBeer(context).isRooted
    
    // The decision lives on the server; the client only applies the policy it gets back
    suspend fun trustPolicy(context: Context, requestHash: String): Policy {
      val token = attest(context, requestHash)              // Play Integrity standard request
    …
  4. 04

    Where does a private key actually live on Android and iOS, and how do you guarantee it cannot be extracted?

    Hard

    The answer to 'where do I put the private key' is 'somewhere you cannot read it either' — both platforms generate the key inside hardware and only ever let you ask it to sign or decrypt.

    // Android — hardware-backed AES key, with the StrongBox fallback people forget
    fun ensureAesKey(alias: String) {
      val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
      if (ks.containsAlias(alias)) return
    
      fun spec(strongBox: Boolean) = KeyGenParameterSpec.Builder(
    …
  5. 05

    How do you keep sensitive content out of screenshots, screen recordings and the clipboard?

    Medium

    You cannot stop someone photographing the screen, so the goal is narrower: keep secrets out of screenshots, out of the recents thumbnail, out of screen recordings, and out of a clipboard other apps can read.

    // Android — block screenshots and recording for a whole screen
    class SecretActivity : ComponentActivity() {
      override fun onCreate(savedInstanceState: Bundle?) {
        window.setFlags(
          WindowManager.LayoutParams.FLAG_SECURE,
          WindowManager.LayoutParams.FLAG_SECURE
    …
  6. 06

    Before an app can ship, what privacy declarations and consent gates does each store require?

    Medium

    Privacy is a shipping gate now rather than a policy document — both stores reject builds whose declarations do not match what the binary actually does.

    // One consent gate — nothing non-essential may start before the user answers
    class SdkGate(private val consent: ConsentStore) {
      fun onConsentResolved(c: Consent) {
        if (c.analytics) AnalyticsSdk.start()
        if (c.crash) CrashSdk.start()
        if (c.marketing) MarketingSdk.start()
    …
  7. 07

    Your app unlocks itself when the biometric API returns true. What is wrong with that, and what does a correct flow look like?

    Hard

    A biometric API that returns a boolean proves nothing, because whoever can patch the app can patch the boolean.

    // The key is the point: it is unusable until the system authenticates the user
    fun createAuthBoundKey(alias: String) {
      val spec = KeyGenParameterSpec.Builder(
        alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
      )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    …
  8. 08

    Any app on the device can register your myapp:// scheme. What breaks because of that, and what has to be true before you act on an inbound link?

    Medium

    A custom URL scheme is claimed on a first-come basis, so treat every inbound link as attacker-controlled input arriving at an endpoint anyone on the device can invoke.

    // Every inbound link is an unauthenticated request from an unknown caller
    fun handleLink(uri: Uri): Route? {
      if (uri.scheme != "https") return null            // ❌ myapp:// is claimable by any app
      if (uri.host != "example.com") return null        // allow-list, never a loose match
      val id = uri.pathSegments.getOrNull(1)?.toLongOrNull() ?: return null
      return when (uri.pathSegments.firstOrNull()) {
    …
  9. 09

    The device is already encrypted end to end. When does encrypting your own files actually add anything?

    Hard

    Full-disk encryption only protects a device that is powered off, so the real question is what an attacker holding your phone after one unlock, or holding your cloud backup, can still read.

    // Pick the protection class per file; the default is readable after the first unlock
    func writeVault(_ data: Data, to url: URL) throws {
      try data.write(to: url, options: [.atomic, .completeFileProtection])
    }
    
    // Keep a file out of iCloud and out of an encrypted local backup
    …
  10. 10

    Product wants to drop passwords and ship passkeys. What has to exist on your domain, on the server and in the app before that works?

    Medium

    A passkey is a public-key credential bound to your domain and held by the platform, so the app never handles a shared secret and a phishing site has nothing to collect.

    // Registration: the server issues the challenge, the platform creates the key pair
    suspend fun registerPasskey(activity: Activity, optionsJson: String) {
      val request = CreatePublicKeyCredentialRequest(requestJson = optionsJson)
      val response = CredentialManager.create(activity)
        .createCredential(activity, request) as CreatePublicKeyCredentialResponse
      api.finishRegistration(response.registrationResponseJson)  // server verifies, stores pubkey
    …
  11. 11

    Users keep turning up with a subscription they never paid for. Where does purchase validation usually go wrong?

    Hard

    Entitlement is a fact about an account on your server, and the classic bug is letting the app decide it from whatever the store SDK reported locally.

    // The client buys, hands the identifier over, and then asks what the server says
    func purchase(_ product: Product, userId: UUID) async throws -> Entitlement {
      let result = try await product.purchase(options: [.appAccountToken(userId)])
      guard case .success(let verification) = result,
            case .verified(let transaction) = verification else { throw PurchaseError.unverified }
      let entitlement = try await api.claim(originalId: transaction.originalID)   // server decides
    …
  12. 12

    The app calls a third-party API with the vendor key compiled into the binary. What do you replace it with?

    Medium

    Anything shipped inside a binary is public, so the fix is never a better hiding place but removing the client's need to hold the secret at all.

    // The app holds no vendor key: it calls your backend, which holds it
    func summarise(_ text: String) async throws -> String {
      var request = URLRequest(url: URL(string: "https://api.example.com/v1/summarise")!)
      request.httpMethod = "POST"
      request.setValue("Bearer \(await session.accessToken())", forHTTPHeaderField: "Authorization")
      let appCheck = try await AppCheck.appCheck().token(forcingRefresh: false)
    …
  13. 13

    A release build turns out to be talking to the staging server over plain http. What is supposed to stop that on each platform, and who switched it off?

    Medium

    Both platforms block cleartext HTTP by default, so a build that sends it carries an explicit opt-out somebody committed — an Android network security config or manifest flag, or an ATS exception in Info.plist.

    <!-- src/main/res/xml/network_security_config.xml, the file that ships -->
    <network-security-config>
      <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
          <certificates src="system" />
        </trust-anchors>
    …
  14. 14

    A support ticket arrives with the user's logs attached, and their access token is sitting in the middle of it. Where do tokens usually get into logs?

    Medium

    Logs leave the device — bug reports, crash reports, analytics breadcrumbs — so every line you print is data you have already shipped to somebody else, and the fix is to redact at the source rather than at the sink.

    import os
    
    // Dynamic values are redacted by default; you opt in per value, never per file
    private let log = Logger(subsystem: "com.example.app", category: "auth")
    
    func didRefresh(session: Session) {
    …
  15. 15

    Testers delete the app, install it again, and are still signed in as the previous user. Why does that happen on iOS and not on Android?

    Medium

    Deleting an iOS app does not delete its Keychain items: the container goes, the Keychain entries stay, so the next install finds the old refresh token and restores a session nobody asked for.

    import Security
    
    // UserDefaults dies with the app; the Keychain does not. That asymmetry is the detector.
    func purgeKeychainOnFreshInstall() {
      let flag = "com.example.app.hasLaunchedBefore"
      guard !UserDefaults.standard.bool(forKey: flag) else { return }
    …
  16. 16

    A user says a fake dialog appeared on top of your payment screen and they tapped Confirm through it. What could the app have done about that?

    Medium

    Android lets other apps draw over yours, so a security-relevant tap has to be discarded when the window is obscured — filterTouchesWhenObscured is the control, and it is off by default.

    class CheckoutActivity : ComponentActivity() {
    
      override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    
        // A Compose app has no modifier for this: set it on the host view
    …
  17. 17

    Your WebView exposes a @JavascriptInterface object so the page can fetch the auth token. What is wrong with that, and what replaced it?

    Hard

    A JavaScript interface is attached to the WebView, not to an origin: every frame of every page that WebView ever loads — a redirect, an ad iframe, an injected script — can call it, so your bridge is an unauthenticated public API of the app.

    // ❌ Reachable from every frame the WebView ever loads
    webView.settings.javaScriptEnabled = true
    webView.addJavascriptInterface(object {
      @JavascriptInterface fun getAuthToken(): String = tokenStore.access()  // no origin check
    }, "Android")
    …
  18. 18

    Fraud asks you to stop one phone from claiming the free trial ten times. What can you actually use as a device identity in 2026?

    Hard

    No stable device identifier is available to apps any more, so the requirement has to be reshaped: what both platforms offer is a few bits of per-developer state that the OS keeps for you and that survive a reinstall.

    import DeviceCheck
    
    // The app can only produce a token. Reading and writing the bits is a server call.
    func claimTrial() async throws -> TrialDecision {
      guard DCDevice.current.isSupported else {
        return try await api.claimTrialWithoutDeviceBits()      // simulator, and older hardware
    …
  19. 19

    A user taps Log out on a phone they just lost, and the API keeps accepting the old token for another hour. What is the design mistake?

    Hard

    Logout was implemented on the client — clearing storage — while the session lives on the server, and a self-verifying access token stays valid until it expires no matter what the client forgot.

    class TokenRepository(private val api: AuthApi, private val store: SecureTokenStore) {
    
      private val refreshMutex = Mutex()
    
      // Single-flight: parallel 401s must not each burn a rotation
      suspend fun refresh(seen: String): Tokens = refreshMutex.withLock {
    …
  20. 20

    A dependency three levels down starts sending data to a host you have never heard of. What in your build and release process is supposed to catch that?

    Hard

    Nothing at runtime will: a library runs inside your process with your permissions, your entitlements and your Keychain access group, so every control that can catch it lives in the dependency graph, the build and the review.

    // Package.swift — a remote binary is pinned by checksum, not by trust
    import PackageDescription
    
    let package = Package(
      name: "App",
      dependencies: [
    …