The practical security checklist I run on every Android app I touch — from the Play Data Safety form to encrypted storage, with the code and the failure modes.
Last year I was asked to review the security posture of an existing Android app before a funding round. The app had 300,000 installs and stored users' payment tokenization references, addresses, and order history. The audit took me a weekend and it was not encouraging.
The app had three problems that together would have made any competent attacker smile: it sent API calls over plain HTTP in two places, it stored a refresh token in plain SharedPreferences, and it logged the full request body — including a bearer token — to Logcat in debug builds that had been shipped to production with minifyEnabled off. None of these were exotic zero-days. They were everyday mistakes. And the Play Data Safety form the team had filed claimed the opposite of what the code did.
Here is the thing about Android security: it is not one big feature you bolt on at the end. It is a set of small, boring, repeatable decisions made in every screen you touch. This article is the exact checklist I walk through — in the order I walk through it — with the code that actually works and the pitfalls that make each layer fail.
Step 1 — File the Play Data Safety Form Truthfully
The Play Data Safety form is not an admin chore; it is a legal statement about your app's behavior, and Google has been enforcing it since 2022. If you say "no data collected" and the app sends analytics, your app gets flagged, suspended, or pulled.
Before you touch any code, enumerate what your app actually does:
- Which data types you collect (location, contacts, emails, financial info, device IDs).
- Where the data goes (on-device only, or to your servers).
- Whether you encrypt it in transit and at rest.
- Whether it is shared with third parties (AdMob, Firebase, crash SDKs).
Every SDK you add — ad networks, analytics, crash reporters — adds data collection you now have to disclose. I keep a PRIVACY.md in every Android repo that lists each SDK, what it collects, and where it sends it. When a new SDK lands in a PR, the PR description must update that file. It turns a scary form into a paper trail you already wrote.
Step 2 — Force HTTPS Everywhere (and Prove It)
Plain HTTP is not a corner case to be tolerated; it is the top of the kill list. On modern Android, cleartext traffic is blocked by default from API 28 (Android 9) onward, but android:usesCleartextTraffic="true" or a permissive network security config silently re-enables it — and I find both in production apps all the time.
The correct move is a network security config that forbids cleartext and pins where you need it:
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.example.com</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>
Then reference it in the manifest and add the Android 7+ default:
<application
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
The cleartext flag is ignored when a network security config is present, so keeping both consistent matters. After this, run the app against a proxy like Burp Suite or mitmproxy and watch for any HTTP request. If one appears, you have a developer who hardcoded a URL somewhere — fix it, do not debate it.
About certificate pinning: it is a strong control, but it is also the one that bricks your app when your certificate rotates and you shipped a hardcoded pin. If you pin, pin a backup set, build pin rotation into your release process, and be ready to ship a hotfix. For most apps, HTTPS with a proper config is enough; pinning is for high-value targets like banking or government apps.
Step 3 — Do Not Trust the Client with Secrets
The single most common leak I find is an API key, client secret, or admin token compiled into the app. I cannot say this loudly enough: anything in your APK can be extracted. A simple strings or a dex decompiler like jadx will find a hardcoded key in minutes, and the Google Play scraping community automates exactly that.
The rule is simple: if a secret is not meant for the user to see, it does not belong in the client at all.
- Move third-party keys that must stay secret to your backend, and proxy the calls.
- For analytics and crash SDKs whose keys are designed to be public (Firebase Web API keys, for example), still restrict the domain and package in the console, and do not use those keys for anything privileged.
- Never, ever put a backend admin token or a database password in the app. I have seen a real production app ship with a
postgres://connection string in its source. Do not be that team.
If you are tempted by BuildConfig fields, remember: BuildConfig is compiled into the APK and is trivially readable. It stops casual snooping and nothing else. Treat it as a placeholder, not a vault.
Step 4 — Encrypt Data at Rest
SharedPreferences and plain files are readable on a rooted device, and worse, they get picked up by backup mechanisms and by careless file exports. Anything sensitive — tokens, user details, offline data — belongs in encrypted storage.
The Jetpack Security library wraps the Android Keystore and gives you EncryptedSharedPreferences and EncryptedFile without you handling keys:
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val prefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit().putString("refresh_token", token).apply()
Every read and write now happens encrypted at the API level; the KeyStore holds the master key outside the app's reach. The two mistakes I see here are wrapping the wrong things (encrypting everything including non-sensitive UI state, which just slows you down) and forgetting that the encrypted prefs themselves still need the right access rules — if the device has no lock screen, the keystore can be weaker. For genuinely sensitive data, require a device lock and consider user-authentication-required keys:
KeyGenParameterSpec.Builder(
"user_auth_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setUserAuthenticationRequired(true)
.setUserAuthenticationValidityDurationSeconds(60)
.build()
Step 5 — Store Only What You Must
Encryption is not a magic shield; if your app holds sensitive data it does not need, encryption just protects a mistake. This is where I do the hard pruning in every audit:
- Do not store the raw password or full credit card number. Store tokens, references, or vaulted data behind your payment provider.
- Do not keep a refresh token forever. Store it with an expiry, rotate it, and revoke it on logout and on account compromise.
-
Do not cache screenshots or clipboard contents. If you build an app that copies sensitive values to the clipboard, clear the clipboard after a timeout, and disable screenshots in sensitive screens with
FLAG_SECURE. - Keep tokens in memory, not on disk. If the app can survive a process restart without the token, do not persist it at all.
Step 6 — Secure the Backup Trail
This is the sneakiest leak. Android auto-backup copies SharedPreferences, databases, and files to Google Drive by default — including, in the past, data from encrypted prefs when the keys could not follow. A restored backup on a new device can re-materialize sensitive data or, worse, a stale session.
In the manifest, disable backup or exclude the sensitive bits:
<application
android:allowBackup="false">
Or keep backup on but exclude what matters:
<application android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules">
<!-- res/xml/backup_rules.xml -->
<full-backup-content>
<exclude domain="sharedpref" path="secure_prefs.xml" />
<exclude domain="database" path="session.db" />
</full-backup-content>
If you use auto-backup, the Data Extraction Rules XML (android:dataExtractionRules) is the modern replacement on Android 12+ — apply both to cover old and new devices. I default to allowBackup="false" for apps that hold financial or health data, and only re-enable it with explicit exclusions when the product genuinely needs it.
Step 7 — Enforce Least-Privilege Permissions
Every runtime permission you request is an attack surface. I audit the manifest for the classic sins: READ_CONTACTS requested by a calculator app, ACCESS_FINE_LOCATION requested always instead of foreground-only, and the bloatware habit of requesting permissions "for future features."
- Request permissions at the moment of need, with an explanation of why.
- Prefer
ACCESS_COARSE_LOCATIONunless fine location is genuinely required. - Re-request is fine, but nagging dialogs get apps removed from consideration by users — and Google ranks unrequested permissions against your declared need.
- Review the permission list on every release. If a permission no longer has a code path, delete it.
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.CAMERA), REQ_CAMERA)
}
Step 8 — Obfuscate and Minify, and Mean It
R8 obfuscation is not security, but it is the fence that makes casual extraction take longer than a lazy attacker is willing to spend. It also shrinks your APK. Too many teams ship with minifyEnabled false because a library broke once under obfuscation and they never went back.
// build.gradle.kts (app module)
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
Expect a fight with reflection-heavy libraries (Gson, Retrofit models, some SDKs) — that is what keep rules are for. Spend the day getting R8 to pass with your real rules instead of disabling it. And critically: R8 does not protect strings. That hardcoded API key is still there, just renamed. Step 3 still rules.
Step 9 — Stop the Logs
Debug logs that leak tokens, PII, or request bodies are a free data breach for anyone with adb access or a decompiled release build that still logs. The fix is systemic, not a hope:
- Do not log full request bodies or auth headers anywhere, ever.
- Gate all your logging behind a build type check so release builds log nothing sensitive:
if (BuildConfig.DEBUG) {
Log.d("App", "onCreate called")
}
- Use
Timberwith a release tree that drops everything except critical error events, and even those go through a redactor that strips emails, tokens, and phone numbers before the crash SDK sees them.
The app I audited logged the refresh token on every login. The fix took one day. The exposure had been running for two years.
Step 10 — Handle the Signing Keys Like Treasure
Your upload key signs your app; whoever holds it can push updates to every one of your users' devices. A leaked upload key is effectively a permanent backdoor. The hygiene is boring and non-negotiable:
- Keep the upload key offline or in a hardware vault, not in the repo, not in CI logs.
- Use Play App Signing so the key that actually signs for distribution is managed by Google, and you keep only the upload key.
- Rotate keys on a schedule and on any suspected exposure.
- Never commit keystore files. A
.gitignoreentry for*.jksand*.keystoreis not optional.
The Common Pitfalls, Compressed
-
usesCleartextTraffic="true"shipped "temporarily" and never removed. - Hardcoded keys because "the backend is not ready."
-
EncryptedSharedPreferencesused, but the master key stored in plain prefs — the most ironic way to fail. - Backup left on, silently copying session data to the cloud.
- R8 disabled because one SDK broke, and nobody ever revisited.
-
allowBackuptrue plus the Keystore-based key unexportable, producing a crash on restore — the failure mode that makes teams disable backup rather than fix the exclusion rules. - Logging PII because "it's just a debug build" and the debug build going to production.
The Release-Gate Checklist
Before I push a release to the Play Console, this list must all be checked:
- [ ] Play Data Safety form matches what the code actually does.
- [ ] No cleartext traffic; network security config forbids HTTP; proxied test passed.
- [ ] No secrets in the APK; backend holds anything privileged.
- [ ] Tokens and sensitive data in
EncryptedSharedPreferencesor memory only. - [ ] Permissions minimized; no unused permissions in the manifest.
- [ ] R8 enabled with working keep rules; resources shrunk.
- [ ] No sensitive logging in release; logs redacted.
- [ ] Backup disabled or explicitly excluding sensitive data.
- [ ] Keystore file not in the repo; upload key rotation on schedule.
- [ ] Third-party SDKs reviewed: what they collect, where it goes, and whether you declared it.
The funding-round app I audited shipped the fixes a month later: encrypted prefs, HTTPS everywhere, R8 on, logging redacted, backup tightened, and a truthful Data Safety form. It also stopped being a story I tell investors with a wince.
Android security is not exotic. It is a checklist, executed honestly, every release. Most data breaches in Android apps are not sophisticated attacks — they are a token sitting in plain prefs and an API call over HTTP. Close those holes first, and you have closed 80 percent of the practical risk before you ever worry about the attacker with the fancy exploit.
*Gulshan Yad
Top comments (0)