Mobile Security (Cross-platform)
Keychain/Keystore, secure storage, certificate pinning, jailbreak/root, App Transport Security
- 01
What is the OWASP Mobile Top 10 and what should every mobile dev know about it?
MediumThe 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( … - 02
What does it take to make mobile API traffic hard to intercept and hard to replay?
MediumNo 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() … - 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?
MediumTreat 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 … - 04
Where does a private key actually live on Android and iOS, and how do you guarantee it cannot be extracted?
HardThe 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( … - 05
How do you keep sensitive content out of screenshots, screen recordings and the clipboard?
MediumYou 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 … - 06
Before an app can ship, what privacy declarations and consent gates does each store require?
MediumPrivacy 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() … - 07
Your app unlocks itself when the biometric API returns true. What is wrong with that, and what does a correct flow look like?
HardA 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) … - 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?MediumA 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()) { … - 09
The device is already encrypted end to end. When does encrypting your own files actually add anything?
HardFull-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
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?
MediumA 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
Users keep turning up with a subscription they never paid for. Where does purchase validation usually go wrong?
HardEntitlement 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
The app calls a third-party API with the vendor key compiled into the binary. What do you replace it with?
MediumAnything 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
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?
MediumBoth 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
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?
MediumLogs 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
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?
MediumDeleting 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
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?
MediumAndroid lets other apps draw over yours, so a security-relevant tap has to be discarded when the window is obscured —
filterTouchesWhenObscuredis 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
Your WebView exposes a
@JavascriptInterfaceobject so the page can fetch the auth token. What is wrong with that, and what replaced it?HardA 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
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?
HardNo 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
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?
HardLogout 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
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?
HardNothing 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: [ …