DEV Community

Konstantin Shkurko
Konstantin Shkurko

Posted on

Secure Data Exchange Between Android Apps: intents, URI schemes, shared preferences

In Android development, sooner or later you'll face the task of passing data between applications. Seems simple enough - send an intent, get a result. But dig a little deeper, and it turns out that behind this simple API lurks a whole zoo of potential security holes. We'll examine three main mechanisms for data exchange in Android: intents, URI schemes, and shared preferences. We'll look at how they work under the hood, where the pitfalls lie, and how to protect your app from prying eyes. If you're writing for Android and want to understand why "just pass the data" is a bad idea, read on.

Intents: the standard way to break something

Let me start by saying that intents are the foundation of inter-process communication in Android. Essentially, they're message-requests that one app sends to the system or another app. Think of an intent as an envelope where you put information and send it either to another screen within your app or to a completely different app.

Simple analogy: you want to open a photo - you create an intent asking "show this picture", the system looks and says: "Oh, there's the Gallery app, it knows how to show pictures" and opens it. Or you want to share a link - you create an intent to "share text", the system shows a list of apps (WhatsApp, Telegram, Email) that can do this.

There are two types of intents:

  • Explicit - you specifically say: "Open this particular screen in my app". It's like addressing a letter to a specific person - relatively safe.

  • Implicit - you say: "I need someone who can open PDFs" or "I need an app for making calls". The system decides who's suitable. It's like writing "To: any doctor" - anyone can respond, and this is where security problems begin.

[!IMPORTANT]

Here and throughout the article, all code examples are simplified for clarity. Production code will require more error handling and edge cases.

// Explicit intent - relatively safe
val intent = Intent(this, TargetActivity::class.java)
intent.putExtra("user_id", 12345)
startActivity(intent)

// Implicit intent - this is where questions begin
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("myapp://profile/12345")
startActivity(intent)
Enter fullscreen mode Exit fullscreen mode

What can go wrong? Any app on the device can register an intent filter for the myapp:// scheme. Imagine: a user clicks a link, and instead of your app, a shady one opens that just collects data. In the past (relatively recently), this is exactly how banking apps were hacked - by intercepting deeplinks with payment information.

How to protect yourself

First rule - never pass sensitive data through implicit intents. If you absolutely must, use App Links (Android 6.0+), which require domain verification.

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data
        android:scheme="https"
        android:host="myapp.com"
        android:pathPrefix="/profile" />
</intent-filter>
Enter fullscreen mode Exit fullscreen mode

Here autoVerify="true" forces Android to verify that you actually own the myapp.com domain. The system will download a JSON file from your server and make sure you have the right to handle these links. Yes, it's additional setup, but it cuts off 90% of deeplink attacks.

Second - always validate incoming data. Never trust what comes in an intent.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val userId = intent.getStringExtra("user_id")

    // Bad - blindly trust the data
    loadUserProfile(userId)

    // Good - validate
    if (userId != null && userId.matches(Regex("^[0-9]{1,10}$"))) {
        loadUserProfile(userId)
    } else {
        // Log suspicious activity
        Timber.w("Invalid user_id received: $userId")
        finish()
    }
}
Enter fullscreen mode Exit fullscreen mode

I've seen code where developers passed SQL queries through intent extras. Yes, you heard that right. The result was predictable - SQL injection through a regular deeplink. Don't repeat others' mistakes.

URI schemes: when the browser becomes an intermediary

URI schemes are a way to launch an app from a browser or another app via a special link like myapp://action/params. In iOS this works through Custom URL Schemes, in Android - through intent filters.

The main problem with URI schemes is the lack of owner verification. Any app can register the same scheme as yours. On Android this turns into an app selection dialog, which the user might accidentally ignore by choosing the wrong one.

<!-- Anyone can register this -->
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" />
</intent-filter>
Enter fullscreen mode Exit fullscreen mode

Classic attack scenario: you make a payment app with URI scheme support for initiating payments. An attacker publishes an app with the same scheme. The user clicks a payment link, selects the wrong app, and voilà - their data is leaked.

Migrating to App Links

I try to avoid custom URI schemes wherever possible. Instead, I use HTTPS links with App Links. The difference is fundamental: the system automatically opens your app without a selection dialog because you've proven domain ownership.

// Handling App Link
val appLinkIntent = intent
val appLinkData: Uri? = appLinkIntent.data

appLinkData?.let { uri ->
    val path = uri.path // /profile/12345
    val params = uri.queryParameterNames // ?ref=email

    // Parse and handle
    when {
        path?.startsWith("/profile") == true -> {
            val userId = path.removePrefix("/profile/")
            openProfile(userId)
        }
        path?.startsWith("/payment") == true -> {
            // Critical: verify payment signature on server
            verifyAndProcessPayment(uri)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What's important: even with App Links, you can't blindly trust parameters. In the payment example above, I always verify data on the server. An attacker can forge a link (for example, through a phishing site), but server-side signature verification won't allow a payment to execute with modified parameters.

Protecting parameters in URIs

If a URI scheme is still necessary (for example, for backward compatibility), add a cryptographic signature.

// Generating a signed link on the server
fun generateSecureLink(action: String, params: Map<String, String>): String {
    val timestamp = System.currentTimeMillis()
    val data = "$action|${params.entries.joinToString("|")}|$timestamp"
    val signature = HMAC_SHA256(data, SECRET_KEY)

    return "myapp://$action?" +
           params.entries.joinToString("&") { "${it.key}=${it.value}" } +
           "&timestamp=$timestamp&signature=$signature"
}

// Verification on the client
fun verifySecureLink(uri: Uri): Boolean {
    val signature = uri.getQueryParameter("signature") ?: return false
    val timestamp = uri.getQueryParameter("timestamp")?.toLongOrNull() ?: return false

    // Check freshness (link valid for 5 minutes)
    if (System.currentTimeMillis() - timestamp > 300_000) {
        return false
    }

    // Verify signature
    val params = uri.queryParameterNames
        .filter { it != "signature" && it != "timestamp" }
        .sorted()
        .joinToString("|") { "$it=${uri.getQueryParameter(it)}" }

    val expectedSignature = HMAC_SHA256(params, SECRET_KEY)
    return signature == expectedSignature
}
Enter fullscreen mode Exit fullscreen mode

Yes, this complicates the code, but it makes intercepting and modifying links pointless. Without knowing the SECRET_KEY, an attacker can't create a valid signature.

Shared Preferences: when private becomes public

Now about the most insidious mechanism - Shared Preferences. By default they're private to the app, but Android allows making them accessible to other apps through MODE_WORLD_READABLE and MODE_WORLD_WRITEABLE.

Good news: these modes are deprecated since API 17 and completely removed in API 24. Bad news: I still encounter code that tries to use them, or worse, stores sensitive data in regular SharedPreferences thinking they're protected.

// DON'T do this
val prefs = getSharedPreferences("user_data", Context.MODE_PRIVATE)
prefs.edit()
    .putString("password", password) // Storing password in plain text
    .putString("credit_card", cardNumber)
    .apply()
Enter fullscreen mode Exit fullscreen mode

Shared Preferences are stored in an XML file in the /data/data/package.name/shared_prefs/ directory. On rooted devices or through ADB backup, anyone can read these files. I once audited a popular finance app and found access tokens stored in plaintext. They didn't even bother using EncryptedSharedPreferences.

EncryptedSharedPreferences: doing it right

With Android Security Library, a proper way to encrypt data appeared.

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val encryptedPrefs = EncryptedSharedPreferences.create(
    context,
    "secure_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

encryptedPrefs.edit()
    .putString("auth_token", token)
    .apply()
Enter fullscreen mode Exit fullscreen mode

Here both keys and values are encrypted using Android Keystore, which is protected at the hardware level (if the device supports it). Even if someone gains access to the file, they can't decrypt it without access to the Keystore.

What's important: EncryptedSharedPreferences don't solve all problems. On older devices without hardware-backed Keystore or on emulators, the protection is weaker. Therefore, critically important data (payment information, medical data) is better not stored on the device at all or use additional encryption at the application level.

Content Providers for inter-process exchange

If you need to securely pass data between your own apps (for example, between the main app and a widget), use Content Provider with proper permissions.

class SecureDataProvider : ContentProvider() {
    override fun onCreate(): Boolean {
        return true
    }

    override fun query(
        uri: Uri,
        projection: Array<String>?,
        selection: String?,
        selectionArgs: Array<String>?,
        sortOrder: String?
    ): Cursor? {
        // Check that the calling app is us
        val callingPackage = callingPackage
        if (callingPackage != context?.packageName) {
            throw SecurityException("Unauthorized access")
        }

        // Return data
        return null // Your logic
    }
}
Enter fullscreen mode Exit fullscreen mode

In the manifest:

<provider
    android:name=".SecureDataProvider"
    android:authorities="com.myapp.provider"
    android:exported="true"
    android:permission="com.myapp.permission.ACCESS_DATA" />

<permission
    android:name="com.myapp.permission.ACCESS_DATA"
    android:protectionLevel="signature" />
Enter fullscreen mode Exit fullscreen mode

protectionLevel="signature" means only apps signed with the same key will get access. This is the ideal option for data exchange between your apps without risk of leaking to third parties.

Instead of a conclusion

Attacker systems constantly evolve, new attacks and new defenses appear. What worked in Android 8 might be insecure in Android 14. Therefore, security in Android isn't a one-time setup, but a process.

My main advice: read Android Security Bulletins, follow CVEs related to your dependencies, and regularly audit your code.

And finally: if you're unsure whether to pass some data between apps - don't. It's better to make an extra server call than to deal with the consequences of a data leak later. Seriously, it's not worth it.

Top comments (0)