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)
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>
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()
}
}
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>
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)
}
}
}
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}" } +
"×tamp=$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
}
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()
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()
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
}
}
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" />
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)