Release signing, R8, network security config, certificate pinning, and secure storage — the hardening checklist I run on every Android build before it ships.
I once reviewed an Android app for a client that had its entire API key, a database password, and a payment gateway secret sitting in a plaintext constants.kt file inside the APK. Anybody could have decompiled the app in under a minute and walked off with production credentials. The app was a year old and had never shipped an update that fixed it, because nobody had ever told the team that an APK is not a secret container — it is a public ZIP file that anyone can open.
Android security in 2026 is mostly boring, and that is exactly the point. The attacks that actually happen are the cheap ones: decompile the app, read the secrets, flip a flag, steal a token. The best practices in this article exist to make those cheap attacks expensive. I have spent years hardening production apps, and this is the checklist I now run on every build — release signing and Play App Signing, R8 minification, the network security config, certificate pinning, secure storage, and the tamper checks that are actually worth the effort.
1. Release Signing and the Keystore — the Line Between Yours and Someone Else's
Your app's signature is its identity. The two mistakes that matter, in order:
Mistake one: using the same keystore for debug and release. Google Play rejects apps signed with a debug key, and mixing keys causes a nightmare of "app already exists" failures. Your release keystore is generated once, kept out of version control, and protected by a strong password.
Mistake two: losing the keystore. If you lose the upload key, you can recover via Play App Signing by re-registering your upload key. But if you lose the app signing key in Play App Signing, the app is dead — you cannot update it, and users keep the last version forever. Store the keystore in at least two places: a password manager and a physical drive, never in the repo.
In 2026, the setup I recommend is Play App Signing with a split key model: Google holds the app signing key, you hold an upload key used only to upload to Play. That way even if an attacker gets your upload key, they cannot re-sign the app under your identity.
// build.gradle.kts (app module)
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("RELEASE_STORE_FILE"))
storePassword = System.getenv("RELEASE_STORE_PASSWORD")
keyAlias = System.getenv("RELEASE_KEY_ALIAS")
keyPassword = System.getenv("RELEASE_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
Note the environment variables: credentials come from CI or a local .env, never from the build file itself. I have seen signing passwords committed to build.gradle more times than I can count, and each one was a credential dump waiting for a leak.
2. R8 Minification and Obfuscation — Make the Cheap Attack Expensive
R8 (the successor to ProGuard, enabled by isMinifyEnabled = true in release) does four things: removes unused code, shrinks resources, renames classes and methods to meaningless names, and can be told to strip logging. It is free defense-in-depth: a decompiled, obfuscated APK is dramatically more painful to reverse than the clean constants.kt layout I found in that client's app.
The three rules that keep R8 from breaking your app:
- Keep rules for reflection and serialization. Any class loaded by reflection or Gson needs a keep rule. The crash usually shows up in release-only QA, so test the release build, not just debug.
-
Strip logging in release. Add
-assumenosideeffectsrules forandroid.util.Logor use a wrapper so debug logs physically disappear from the release APK — logs leak URLs, tokens, and data. -
Keep a mapping file. R8 produces
mapping.txt; upload it to Play Console or keep it in CI so your crash reports map back to readable names. A release build without its mapping file is an app you cannot debug.
# Keep models used by Gson (never rely on reflection-safety by accident)
-keep class com.yourapp.data.models.** { *; }
-keep class com.google.gson.reflect.TypeToken { *; }
# Strip logging in release (after verifying nothing depends on it)
-assumenosideeffects class android.util.Log {
public static int d(...);
public static int v(...);
public static int i(...);
}
The honest limit: R8 obfuscation is a speed bump, not a wall. A determined reverse engineer will still get through it. Its real value is removing the embarrassing default — the app that leaks its secrets in plain text on the first decompile.
3. Network Security Config — HTTPS Everywhere, Enforced
Android has supported HTTPS-only enforcement since Android 9 (API 28), and cleartext traffic is the kind of thing you fix in one file. The default network security config denies cleartext — which means if your app currently talks to http://your-api.com, it will break, and breaking it is correct, because that traffic is readable by anyone on the network.
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<!-- Enforce HTTPS everywhere; no cleartext at all -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
<!-- If you MUST allow a specific dev endpoint, scope it tightly -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain> <!-- emulator loopback only -->
</domain-config>
</network-security-config>
Reference it from the manifest:
<application
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false" >
The rule of thumb: cleartextTrafficPermitted="false" globally, and if you genuinely need a plain-HTTP dev endpoint, allow it only for a loopback address, never a wildcard domain. The number of "secure" apps I have seen with a wildcard cleartext domain for "just staging" is higher than I would like to admit.
4. Certificate Pinning — With a Working Rotation Plan
Pinning means your app verifies not just that the connection is TLS, but that the server presents the exact certificate or public key you expect. It defeats man-in-the-middle attacks that work by installing a rogue CA on the device. In 2026, the practical approach is public key pinning — pin to a key hash rather than a certificate — so certificate renewals do not break your app.
val certificatePinner = CertificatePinner.Builder()
.add("your-api.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("your-api.com",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup key
.build()
The second hash is the rotation strategy: when your primary key rotates, you ship an update that adds the new key while the old one still works. Without a backup pin, a certificate renewal bricks every active install of your app, and that is how otherwise-sensible teams talk themselves out of pinning entirely.
The caveat that keeps pinning honest: never pin your CDN or third-party analytics domains — those rotate certificates and IPs constantly, and you will turn a minor infra change into a full app release. Pin only the API endpoints you fully control.
5. Secure Storage — Keystore, Not SharedPreferences
SharedPreferences is plaintext on disk. Any app on the device with the right permissions (or an attacker with root) can read it. Secrets belong in the Android Keystore, which keeps private keys in a hardware-backed secure element on modern devices.
For app data, the right answer in 2026 is EncryptedSharedPreferences, which wraps your preferences with keys held by the Keystore:
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("access_token", token).apply()
The rules that matter: tokens and PII go in EncryptedSharedPreferences or the Keystore, never SharedPreferences; hardware-backed Keystore keys can be configured with setUserAuthenticationRequired(true) for sensitive operations; and never log a token, ever, in any build. The Keystore is the one place on Android where a secret is stored as hardware, not as a file someone can copy.
6. Tamper and Integrity Checks — the Ones That Earn Their Keep
Root detection and integrity checks get a bad name because a naive implementation bans half your user base. The version I recommend is targeted and honest:
- Signature verification at startup. Confirm your app was signed by your key. Cheap, effective, and it blocks repackaged "modded APK" versions of your app.
- Basic root detection, but as a warning, not a hard block — legitimate power users exist, and you want to know, not ban.
- Play Integrity API for anything that matters financially — it is Google's server-side attestation and it is the strongest signal you can get about whether the device and app are genuine.
The warning that has saved me repeatedly: an integrity check that returns a false positive is worse than no check at all, because it locks out paying users. Ship integrity as data (log it, alert on spikes), make blocking a policy decision per check, and never let a root-detection heuristic be the sole gate on a revenue flow.
7. The Supply Chain You Forget: Dependencies and Build Secrets
The cheapest exploit in 2026 is not against your code — it is against your dependencies and your build machine. Most apps pull in hundreds of transitive libraries, and any one of them can be a poisoned package: a typosquat that slipped into a popular repository, or a legitimate library whose maintainer account was hijacked. The mitigations are boring and effective:
-
Lock your dependencies. Commit
gradle.lockfile(or the Gradle dependency locking plugin) so builds are reproducible. An unlocked Gradle build can silently resolve a different version of a dependency a month later — and you will never notice. - Scan every dependency before release. Run an SBOM (software bill of materials) generator on each build and feed it into a vulnerability scanner. The OWASP Dependency-Check plugin is free, runs in Gradle, and flags known CVEs in your dependency tree before you ship them.
-
Treat your CI runner as a production server. Secrets in CI must come from the platform's secret store, never from the repo. A leaked
gradle.propertieswith a signing password has the same blast radius as a leaked server key. -
Ask what a library actually needs. A UI animation library that requests
INTERNETandREAD_EXTERNAL_STORAGEat runtime should raise an eyebrow. Audit the permissions your dependencies declare in the merged manifest, not just the ones you wrote.
The reason this section exists: I audited one app where the signing keystore password sat in a committed gradle.properties, the dependency tree had an unpinned vulnerable JSON parser, and a third-party SDK was declaring permissions the app never asked for. None of that shows up in a static review of your own code. All of it showed up in a supply-chain pass — and all of it was exploitable.
8. Android 12+ Opt-Outs That Matter for Privacy
Android 12 (API 31) and later give you two controls that users increasingly check before installing:
-
Approximate location. If your app only needs a city-level location, declare the
ACCESS_COARSE_LOCATIONonly and never request precise. Reviewers and privacy-conscious users notice when a calculator wants your GPS. -
Uninstall attribution. Declare
ALLOW_UNINSTALL_AND_UPDATEin your app's admin policy only if you genuinely need it; abusing device admin is a fast route to an unfavorable review.
Privacy hygiene is part of security because a leak is a leak whether the attacker is a hacker or an ad SDK. Every SDK you include is a new party with a copy of your data — prune them as aggressively as you prune code.
The Pitfalls Checklist
Before every release, I run this list:
- [ ] Release keystore generated, backed up in two places, never in the repo
- [ ] Play App Signing enabled with split key model
- [ ] R8 minification + resource shrinking on; release build crash-tested, not just debug
- [ ] Logging stripped from release builds; mapping.txt preserved
- [ ]
cleartextTrafficPermitted="false"globally; dev endpoints scoped, not wildcarded - [ ] Certificate pinning on your own API domains only, with a backup key for rotation
- [ ] Secrets in EncryptedSharedPreferences/Keystore, never SharedPreferences
- [ ] No credentials, tokens, or secrets anywhere in app code or resources
- [ ] Integrity checks returning data, with blocking gated per check
- [ ]
mapping.txtuploaded so release crashes are readable - [ ] Dependency tree locked and SBOM-scanned for CVEs per build
- [ ] CI secrets from the platform secret store, never from the repo
- [ ] Merged manifest audited for unexpected SDK permissions
The Honest Closing
No checklist makes an Android app unhackable — it makes it expensive to hack, and that is the actual goal. The attacks that succeed against most apps are the lazy ones: a plaintext API key, a cleartext endpoint, an unpinned connection, a token in SharedPreferences. Each best practice in this article closes one of those cheap doors. The client app I reviewed had seven of them open. After a hardening pass, it shipped with the key in the Keystore, HTTPS enforced, logging stripped, and an app signing model that meant losing the upload key would not kill the product.
Security on Android is not exotic cryptography; it is a series of boring defaults done deliberately. Do those boring defaults right, in the order above, and the first thing a decompiler finds in your APK will be nothing worth taking.
*Gulshan Yad
Top comments (0)