I use the OWASP Mobile Top 10 (2024) as a checklist of controls: no embedded secrets or plain-text storage, authorization on the server, TLS without trust-all, validated deep links and non-exported components, vetted SDKs, and R8 as a cost-raiser only.
Credentials, storage, crypto (M1, M9, M10): no API keys in the APK, tokens encrypted with a Keystore key, nothing secret in plain SharedPreferences, no home-grown algorithms
Authentication and authorization (M3): the server decides who may do what; a flag on the client is only a suggestion
Transport (M5): no cleartext, no trust-all TrustManager, pinning only with a rotation plan
Input and platform surface (M4, M8): validate deep links and intent extras, android:exported="false" by default, no WebView bridge open to every origin
Supply chain and privacy (M2, M6): pinned SDK versions and a review of what each new SDK collects and sends
Binary protection (M7): R8 and attestation raise the cost of tampering; they never make the client trusted
AndroidSenior
Mobile Security (Cross-platform)
Keychain/Keystore, secure storage, certificate pinning, jailbreak/root, App Transport Security
Questions closed
0 / 24
- 0Review
- 2Asked often
Questions
Encoding changes the format and anyone can reverse it, encryption hides data from anyone without the key, and hashing is a one-way fingerprint that cannot be reversed at all. On Android the encryption key lives in the AndroidKeyStore, never in the code.
Encoding: Base64, URL encoding; no secret is involved, so a Base64 token is still the token
Hashing: SHA-256 checks integrity; passwords on the server need a slow salted hash such as Argon2id or bcrypt, because a fast hash is brute-forced on a GPU
Symmetric encryption: one key encrypts and decrypts (AES-GCM); fast, used for data at rest and inside TLS
Asymmetric encryption: a public and private key pair (RSA, EC); slow, used for signatures and key agreement, not for bulk data
Where the key lives: generate it inside AndroidKeyStore so the raw bytes never enter your process; the app can only ask the Keystore to encrypt, decrypt or sign
TLS uses both kinds: an ECDHE key agreement, authenticated by the server certificate, produces a symmetric AES or ChaCha20 session key.
Keep it out of git by reading it from local.properties or a CI secret at build time. Anything compiled into the APK is still public, so a key that grants real power stays on your server and the app calls your backend instead.
Out of git: local.properties is git-ignored and CI injects the value from its secret store; a key that ever reached git history is burned and must be rotated
**BuildConfig is not hiding**: apktool or strings recovers it in minutes, and R8 does not touch string constants
Proxy anything that costs money or reads user data: your backend holds the vendor key, authenticates your own session and rate-limits per account; rotating the key then needs a deploy, not a store release
If a key must ship: restrict it at the vendor: package name plus signing certificate SHA-256, an API allow-list, a quota and a billing alert
Some keys only look secret: a Firebase config or a crash-reporter DSN is an identifier; security rules and App Check protect the backend
Both platforms block cleartext HTTP by default, so a release build that sends it carries an opt-out somebody committed: an Android network security config, or an ATS exception in Info.plist.
Android: cleartext is off by default for apps targeting API 28+, but network_security_config.xml can allow it per domain, and that is where the exception hides
Keep it out of release: put the permissive file in src/debug/res/xml/; a file in src/main applies to every build type
**debug-overrides: the place for the trust anchor an interception proxy needs; it is ignored unless the build is debuggable
iOS**: NSAllowsArbitraryLoads disables ATS app-wide; a per-host NSExceptionDomains entry is the narrow version, and it belongs in the debug configuration's Info.plist only
The iOS gotcha: if NSAllowsArbitraryLoadsInWebContent or NSAllowsLocalNetworking is present, NSAllowsArbitraryLoads is ignored entirely
Give staging a real certificate, and check the merged release config in CI.
The TLS stack checks that the certificate chain is signed up to a root CA the device trusts, is within its validity dates and names the host you dialled. Pinning narrows that further: only a certificate carrying a public key you shipped is accepted.
Chain: the leaf is signed by an intermediate, the intermediate by a root in the system store; since Android 7 apps ignore user-installed CAs unless the network security config opts in
Hostname: the name must match a Subject Alternative Name; this is the check a custom HostnameVerifier most often breaks
What pinning adds: protection against a mis-issued or rogue CA certificate, and interception through a proxy CA fails even on a rooted test phone
Pin the SPKI hash, not the certificate: the key survives certificate renewal; pin your intermediate or your own key and always ship a backup pin
Declarative option: <pin-set expiration="..."> in the network security config; after that date the pins stop applying instead of bricking old installs
The cost: a missed key rotation breaks every installed copy at once, so many teams pin only high-value APIs and rely on attestation for the rest
It switches off TLS authentication completely: any certificate from anyone is accepted for any hostname, so whoever sits on the network path can read and change every request. If the code lives in src/main, it ships to production.
**Empty checkServerTrusted: no chain is ever rejected, so a self-signed certificate from a proxy on café Wi-Fi passes
hostnameVerifier { _, _ -> true }: even a valid certificate issued for another domain is accepted
Encryption without authentication is worthless: the traffic is still encrypted, but to the attacker's key
Google Play scans for it**: an unsafe X509TrustManager or HostnameVerifier triggers a policy warning and can block updates
The right fix: trust the staging CA only in debug: a network security config in src/debug with <certificates src="@raw/staging_ca"/> for that domain, or simply give staging a real certificate
The pins are registered for example.com, but the client calls api.example.com. OkHttp matches pins by exact hostname, finds none for that host and silently skips the check, so any certificate the device trusts is accepted.
A plain pattern is one host: example.com pins only example.com; *.example.com covers exactly one extra label (api.example.com), **.example.com covers any depth and the apex itself
No match means no pinning, not an error: a host without pins is treated as unpinned, so nothing fails in development and the setup looks right in review
The fix: pin the host from BASE_URL, or **.example.com when every subdomain is served with the same key, and keep the backup pin; any other host the app dials (CDN, auth server) is unpinned unless it is listed too
Catch it: a unit test that asserts pinner.findMatchingPins("api.example.com") is not empty, plus a release check through mitmproxy with its CA in the test phone's system store: every API call must fail with SSLPeerUnverifiedException
The key is compiled into the APK, so anyone who unpacks the app can decrypt every user's token; the fixed all-zero IV and the missing integrity check make it weaker still.
Hardcoded key: the string sits in the dex file; encryption is only as secret as its key, so this is obfuscation, not protection
Constant IV: CBC with the same IV turns equal plaintexts into equal ciphertexts and leaks shared prefixes; the IV must be random per message and stored next to the ciphertext
No authentication: CBC without a MAC lets an attacker flip bits undetected; AES-GCM encrypts and authenticates in one step
**Cipher.getInstance("AES") would be worse: it silently means ECB, which leaks patterns in the data
The fix**: a key generated inside AndroidKeyStore, AES-GCM with the random IV the cipher picks, and that IV stored with the result
Anywhere an object holding the token is turned into a string: a data class's generated toString(), an exception message, a crash breadcrumb or an SDK's own debug log. All of those leave the device with the report.
**Generated toString()**: data class Session(val refreshToken: String) prints the token wherever the object is logged; give credential types a toString() that prints only its length
Exception messages: IllegalStateException("bad response: $body") ships the body to your crash reporter
Breadcrumbs and analytics: navigation arguments, deep-link URLs with a code in the query, user properties
Third-party SDK logging: verbose mode left on in release; set each SDK's log level per build type
Strip debug logs in release: an R8 -assumenosideeffects rule removes Log.d and Log.v calls; other apps cannot read your logcat, but adb logcat and every bug report can
Set FLAG_SECURE on the window to block screenshots, blank recordings and the Recents thumbnail, and mark copied secrets with EXTRA_IS_SENSITIVE and clear them after a timeout. You cannot stop someone photographing the screen.
**FLAG_SECURE: blocks screenshots, blanks the window in recordings and casts, and hides the Recents thumbnail; it is per window, so dialogs and bottom sheets need it too
Detecting instead of blocking**: Android 14 registerScreenCaptureCallback reports a screenshot after the fact, Android 15 addScreenRecordingCallback reports that the app is being recorded; both need a normal install-time permission
Clipboard: never copy a secret automatically; on Android 13+ set ClipDescription.EXTRA_IS_SENSITIVE so the preview hides it, and clear the clip after a timeout with clearPrimaryClip()
Password fields: SecureTextField in Compose or inputType="textPassword" keeps the value out of the keyboard's learning
**iOS has no FLAG_SECURE**: cover the window when the scene resigns active so the app-switcher snapshot is blank, and watch UIScreen.isCaptured during recordings
This is tapjacking: another app draws over yours and the user's tap lands on your button. Set filterTouchesWhenObscured on security-relevant views so touches are dropped while another window covers them; it is off by default.
How it works: the framework discards a touch delivered while another app's window covers yours; every dialog, sheet or popup is a separate window and needs the flag too
Android 12 narrowed the attack: touches that pass through another app's overlay are blocked unless that overlay is at most 80% opaque, so what remains is a translucent or partial cover
Hide overlays outright: on API 31+ Window.setHideOverlayWindows(true) removes other apps' overlays while a sensitive screen is visible
Explain instead of swallowing: check MotionEvent.FLAG_WINDOW_IS_OBSCURED and tell the user why the tap was ignored, or the button just looks broken
What it does not cover: an accessibility service reads the screen and injects clicks with no overlay at all; for anything that moves money, confirm on the server side
iOS: apps cannot draw over other apps, so this attack class does not exist there
A custom scheme like myapp:// is not owned by anyone, so another app can receive links meant for you. Treat every inbound link as attacker-controlled input to an endpoint anyone on the device can call.
Use links the OS verifies: App Links (assetlinks.json) and Universal Links are tied to a domain you own
Prefer a verified https redirect for OAuth: a competing app can receive the code on a custom scheme; if you must use one, PKCE makes the stolen code useless
Treat the link as an unauthenticated request: re-check the session and the user's rights on the server, allow-list host and path, confirm anything destructive on screen
Nothing secret in a URL: links end up in browser history, server logs and the referrer header
Intent redirection: keep android:exported="false" by default and never start an Intent taken out of another app's extras: it reaches your private components through you
endsWith("example.com") also accepts evilexample.com, a domain anyone can register. Any web page or app can fire myapp://web?url=https://evilexample.com/ and the activity opens the attacker's page with the user's access token in the Authorization header.
Why a suffix check fails: a host is a sequence of labels, not a string: evilexample.com ends with the same characters but is a different registrable domain; contains and startsWith checks fall the same way (example.com.evil.io)
The scheme is part of the check: the same code also loads http://shop.example.com, which sends the token in clear text to anyone on the network path
The fix: parse the URL once with a strict parser (OkHttp toHttpUrlOrNull()), compare scheme with "https" and host with an exact set of hosts, and load that same parsed value, because Uri.host and the browser parser can disagree on \\ and @; even endsWith(".example.com") trusts every subdomain, including a forgotten one somebody has taken over
Catch it: unit-test the validator with hostile inputs (https://evilexample.com, http://example.com, https://example.com.evil.io, https://evil.com\\@shop.example.com) and treat every exported activity that reads a URL from an intent as a security review item
Deleting an iOS app does not delete its Keychain items. The app container goes, the entries stay, so the next install finds the old refresh token and silently restores the previous session.
Why: Keychain items belong to an access group derived from your Team ID, not to the installed binary; even ThisDeviceOnly items survive a reinstall on the same phone
The fix is indirect: UserDefaults is deleted with the app, so a missing first-run flag means a fresh install: purge your Keychain items before anything reads a session
Pick the accessibility class deliberately: WhenUnlockedThisDeviceOnly for a session token; a default item rides an encrypted backup onto a new phone, and kSecAttrSynchronizable syncs it through iCloud Keychain
Android differs by default: uninstall removes the data directory and the app's AndroidKeyStore keys, so a sideloaded reinstall starts clean; a Play reinstall can still restore files through Auto Backup, so keep tokens out of backup (noBackupFilesDir or exclusion rules)
It is a classic cross-platform question because the same Flutter or KMP code behaves differently on the two platforms.
Play Integrity gives your server a Google-signed verdict on a request: is this the binary Play distributed, does the device pass integrity checks, is the user licensed. A root check runs inside the attacker's process and returns whatever they hook it to return.
What it attests: appIntegrity (PLAY_RECOGNIZED), device labels MEETS_BASIC_INTEGRITY, MEETS_DEVICE_INTEGRITY and MEETS_STRONG_INTEGRITY, accountDetails (LICENSED), plus optional signals such as risky apps running and Play Protect status
The server decides: the app only gets an opaque token; your backend decodes it through Google's decodeIntegrityToken endpoint and checks package name, request hash and timestamp
Bind it to the action: pass a hash of the request with setRequestHash so a token captured for login cannot be replayed for a payment
What it cannot do: it does not stop a genuine, unmodified phone in a bot farm, and it says nothing about a stolen session; it raises cost, it is not identity; calls also have a daily quota
Degrade, do not block: custom ROMs fail device integrity honestly; step up authentication or disable risky features instead of locking users out, and keep client root checks as an analytics signal only (on iOS, App Attest plays this role)
A passkey is a key pair 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. Your domain must vouch for the app, your server must run the WebAuthn ceremony, and the app calls the platform API.
Domain: assetlinks.json must carry the delegate_permission/common.get_login_creds relation for your package and signing hash; on iOS an Associated Domains webcredentials entry plus the hosted association file
Server: issues a challenge naming your domain as the relying party, then verifies the signed response and stores the public key; this is a WebAuthn server, usually a library
Android: Credential Manager: createCredential to register, getCredential to sign in, one sheet for passkeys and saved passwords during migration
The association file is a hard prerequisite: a broken one gives an empty sheet that looks exactly like an SDK bug
Keep a recovery path: a user who loses their Google or Apple account loses the credential
Common mistake: treating the local biometric as the authentication; the server verifying the signed challenge is
Generate the key inside secure hardware and never hold it yourself: the Android Keystore and the iOS Secure Enclave only let your app ask the key to sign or decrypt, and key attestation lets your server verify that this is really the case.
Android: create the key through the AndroidKeyStore provider with KeyGenParameterSpec; the TEE or the StrongBox chip holds the material and it never enters your process
StrongBox is optional hardware: setIsStrongBoxBacked(true) throws StrongBoxUnavailableException on many phones, so fall back to the TEE
Attestation turns a promise into a fact: setAttestationChallenge(...) produces a certificate chain; the server checks that it chains to Google's attestation roots, contains its own challenge, names your package and signing certificate, and reports a hardware security level
Non-extractable is not non-usable: malware or root on the device can still ask the Keystore to sign; gate high-value keys behind user authentication
iOS: Secure Enclave keys are EC P-256 (RSA is not supported; iOS 26 CryptoKit adds post-quantum ML-DSA/ML-KEM types); SecAccessControl with .biometryCurrentSet ties use to the current biometrics, and App Attest gives the server similar proof
A biometric API that returns true proves nothing: whoever can patch the app can patch the boolean. In a correct flow the biometric unlocks a key, so a successful decryption is the proof and there is no branch to flip.
Android: BiometricPrompt with a CryptoObject over a key created with setUserAuthenticationRequired(true); the Cipher works only after the system has authenticated the user
Only Class 3 can gate a key: BIOMETRIC_WEAK cannot carry a CryptoObject, so decide in advance what a face-unlock-only phone does
Re-enrolment is a signal, not a crash: with setInvalidatedByBiometricEnrollment(true) a new fingerprint makes Cipher.init throw KeyPermanentlyInvalidatedException; log in fully and re-wrap the token under a fresh key (wrapping needs one prompt too, or encrypt with the public half of an asymmetric key); after a permanent lockout use a full login or AUTH_BIOMETRIC_STRONG or AUTH_DEVICE_CREDENTIAL
iOS: keep the secret in the Keychain behind SecAccessControl with .biometryCurrentSet, so reading the item is what triggers Face ID; LAContext.evaluatePolicy alone only returns a Bool
Common mistake: storing the password to replay it; store a refresh token wrapped by the biometric-bound key
R8 renames your classes and members and strips unused code, but strings, framework calls, resources and the logic itself stay readable, so patching out a check is an afternoon's work. Any detection runs in a process the attacker controls, so it is a signal for the server, which makes the decisions.
What R8 hides and what it does not
Hidden: your own names (a.b.c() instead of PremiumGate.isActive()), unused code, some structure after inlining and class merging.
Still readable: string constants that point straight at the check, every call into the framework and the JDK (PackageManager, Cipher), resources, the manifest and native libraries. jadx turns the dex back into Java, apktool rebuilds the APK from edited smali, apksigner signs it with the attacker's key.
Detecting repackaging
A re-signed APK carries a different certificate: compare the SHA-256 of signingInfo.apkContentsSigners with the Play app signing key, not your upload key, or every Play install looks tampered.
The attacker patches that check out too. What they cannot forge is a Play Integrity verdict checked on the server: appRecognitionVerdict is UNRECOGNIZED_VERSION for a binary Play did not distribute.
Detecting Frida and hooks
Frida needs no modified APK: on a rooted phone frida-server rewrites methods at runtime, so isPremium() returns true without a byte patched.
Signals: frida in /proc/self/maps, the default port 27042, a connected debugger, Xposed or LSPosed classes. Frida can hook the detector first, so the signals travel with the integrity token and the server steps up (re-authentication, payment limits) instead of the app crashing.
Device encryption (file-based on Android, Data Protection on iOS) protects a phone that is off or not yet unlocked after boot. After that your files are readable by anything running as your app, so app-level encryption helps only when its key has a property the OS key lacks.
A key that needs the user present: setUserAuthenticationRequired(true) on Android, .biometryCurrentSet on iOS; the data stays unreadable even to your own process until the user authenticates
A key you can destroy: deleting one Keystore alias or Keychain item makes gigabytes unreadable at once, which is what makes logout and remote wipe real
A key that does not travel: hardware keys are not in backups, so restored files stay unreadable; plan for that
Caches leak more than the database: HTTP cache, image cache, WebView storage and logs; logout has to delete them as well
iOS file protection: the default class opens files after the first unlock; .completeFileProtection closes them again while the device is locked
If none of these properties matters for your threat model, extra encryption only adds a key-management bug.
A JavaScript interface is attached to the WebView, not to an origin. Every frame of every page it ever loads (a redirect, an ad iframe, an injected script) can call it, so the bridge is an unauthenticated public API of your app.
**addJavascriptInterface has no origin check**: the @JavascriptInterface annotation closed the old reflection exploit, but not reachability by untrusted content
The replacement: WebViewCompat.addWebMessageListener takes allowed origin rules and delivers postMessage only from matching frames; where it is unsupported, ship no bridge rather than the old API
iOS: WKUserContentController.add(_:name:) has the same all-frames problem; use a content world and check message.frameInfo.isMainFrame and the security origin
Serve local content over https: WebViewAssetLoader instead of file://, allowFileAccess off, and never call proceed() in onReceivedSslError
Design the bridge like a published API: origin-scoped, a few verbs, no method that hands out the token; if the page needs to call your API, give it a scoped, short-lived token of its own
The access token is self-contained, like a JWT: the API trusts it until it expires and never asks the server whether the session still exists, so logout has to be a server-side revocation, not a local wipe.
Two tokens, two jobs: the access token is checked without a database lookup, which is why its lifetime must be minutes, not hours; the refresh token is the session and the server checks it on every use
Logout is a server call: revoke the refresh token (the RFC 7009 revocation endpoint) so the server ends that session, then clear storage; offer sign-out of all devices for the lost-phone case
Rotate refresh tokens: each refresh returns a new one and invalidates the old; a token used twice means theft or replay, so the server kills the whole session family
The production-only bug: concurrent 401s must share one refresh (single-flight), or the second presents a rotated token and logs the user out
When minutes are too long: keep a server-side denylist of revoked session ids that the API checks for high-risk calls
Make a stolen token useless: DPoP (RFC 9449) binds the token to a Keystore or Secure Enclave key, so copying the token off the device is not enough
There is no stable hardware identifier available to apps any more, so reshape the requirement: ask whether this device has already claimed the trial, not who this device is, and keep that fact on the server.
**ANDROID_ID: scoped per app signing key since Android 8 and reset by a factory reset; IMEI and serial number need privileged permissions since Android 10
The advertising id**: needs the AD_ID permission, turns into zeros when the user deletes it, and is meant for ads, not fraud
A UUID you generate is an install id: on Android it dies with uninstall; on iOS a Keychain copy survives reinstall but also rides a backup to another phone unless it is ThisDeviceOnly; identifierForVendor resets once all your apps are deleted
What is built for this: Play Integrity device recall stores three bits per device that survive reinstall and factory reset (still beta and enrolment-only in 2026); iOS DeviceCheck gives two bits per device per developer; your server reads and writes both
Fingerprinting: model, locale and screen size is unstable and a store policy problem
Spend one bit on trial consumed and back it with account and payment signals: a determined abuser simply buys another phone.
Nothing at runtime will catch it: a library runs in your process with your permissions, your files and your Keystore keys. The controls that work live in the dependency graph, the build and the release review.
There is no sandbox between your code and an SDK: it can read your SharedPreferences, use every permission the user granted and talk to any host
Build time is the wider hole: a Gradle plugin or an annotation processor runs arbitrary code on the CI machine that holds your signing keys, without shipping a line into the app
Pin and verify: exact versions in the version catalog, Gradle dependency locking, verification-metadata.xml with SHA-256 checksums, and wrapper validation in CI so a swapped gradle-wrapper.jar fails the build
Update automatically, merge deliberately: Renovate or Dependabot with a cooling-off window, and a human reading the changelog of anything that touches the network or storage
Diff the traffic: proxy a clean install of the release candidate and compare its host list with the last release; that is the check that catches this case, and the one teams skip
Watch the merged manifest: a new permission or exported component from a library shows up there before it shows up in a store review