Android App Security: Best Practices

Security is important and should never be ignored.

Mobile apps often deal with really private and sensitive user data like personal health information or banking information. Losing data or getting hacked, therefore, can have huge consequences.

In this article, we’ll look especially on the Android-site of App Security. Android Apps run on thousands of different devices which all bring along different hardware configurations and software environments. That makes it especially hard to keep an eye on every possible loophole.

We’ll show you best practices to keep your users’ data secure in Android Apps. We are going to talk about how you can implement mobile app security for storing data, communicate between client and server and other aspects like logging and analytics.

The basics of Android App Security

Especially on Android, you need to be aware of each Android version and the security features it provides. Your app might be secure on your brand new mobile phone that you got for Christmas. However, if it’s not also secure on older phones, you expose your users to a very real security risk. That might damage your reputation, or even get you into big legal trouble if you stored some passwords the wrong way.

In this article, we completely focus on the Android-specific characteristics of Mobile Security. If you need a broader overview, check our previous article about Mobile App Security Best Practices.

Best practices for storing user data

As a general rule of thumb: save app-specific data on the internal storage of the device. That prevents other apps from accessing it. Special care is advised for the external storage visible to other apps. If these files contain any sensitive data, you have to take care of the encryption yourself.

Android App Sandbox

The Android sandbox uses the user-based protection of Linux to isolate apps into their own space. Each app is handled as a separate user. No App has access to files of another App, except if the file has been explicitly shared. Compromising the isolation of an app would mean compromising the Linux kernel itself.

Google also announced to switch to the mainline kernel, instead of keeping their custom version up to date with security patches. That should ensure faster security updates in the future to improve Android’s overall security.

File Encryption

If you are saving data on the external storage and don’t want people to access it, encrypt it! The new Crypthography Support Library might come in handy in such a case. Here is how you read data from an encrypted file:

val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC
val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec)

val directory = "./directory"
val fileToRead = "my_sensitive_data.txt"
val encryptedFile = EncryptedFile.Builder(
    File(directory, fileToRead),
    context,
    masterKeyAlias,
    EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()

Writing works similarly. Handling the key with which you encrypt/decrypt your files is just as important. Hardcoding such keys into the app is a really bad practice. A hacker might simply decompile your app and search for your hard-coded key in the code.

Instead, store it in the KeyStore. That is a secure place that is encrypted with your phone’s password.

Encrypted Key-value Storage

SharedPreferences is the way to go for simple key-value-storage. SharedPreferences already have a scoped visibility so that only the app itself can access the values. However, the stored values are not encrypted. Suppose there is an unknown Android vulnerability so that some app can somehow read another app’s SharedPreferences. If you store sensitive data, you want to make sure you are also covered in that case.

For Android 23+ exists a fresh (still in alpha) androidx.security:security-crpyto support library, which helps you store data encrypted in your SharedPreferences. It handles all the encryption for you and uses the AndroidKeyStore in the background.
EncryptedSharedPreferences is used the same way as SharedPreferences, we can just replace our old SharedPreferences with it:

val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)

val encryptedSharedPreferences = EncryptedSharedPreferences.create(
    "SharedPreferencesFilename",
    masterKeyAlias,
    applicationContext,
    PrefKeyEncryptionScheme.AES256_SIV,
    PrefValueEncryptionScheme.AES256_GCM
)

encryptedSharedPreferences.edit()
    .putString("textKey", "text example")
    .apply()

Of course, this makes writing and reading performance slightly worse. However, the difference is hardly noticeable if you are not reading and writing 100 times per second.

The API can be used from API 23 and upwards. For earlier Android versions, you still need to do lots of this stuff manually. You can use the normal SharedPreferences, but encrypt the values yourself. This is a lot more work than just using EncryptedSharedPreferences but eventually provides you with the same security. In the next section we’ll talk about ways to manually encrypt chunks of data.

Cryptographic APIs

Sometimes you might want to manually encrypt data that you e.g. store on the external storage or anywhere else. If you don’t want to be bound to a specific high-level API for e.g. file encryption or want to do more sophisticated stuff, here is a way to apply encryption at a lower level to your data.

First, you have to define the parameters for generating an encryption key:

val keyGenParameterSpec = KeyGenParameterSpec
    .Builder("KeyAlias", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
    .setBlockModes(BLOCK_MODE_GCM)
    .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
    .setRandomizedEncryptionRequired(true)
    .build()

The combination of KeyGenParameterSpec and KeyGenerator allows us to create a key(-pair) for symmetric or asymmetric encryption. Here is how you can achieve this for symmetric keys:

val keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM_AES, "AndroidKeyStore")
val secureRandom = SecureRandom()
keyGenerator.init(keyGenParameterSpec, secureRandom)
keyGenerator.generateKey()

Passing the AndroidKeyStore to the KeyGenerator directly saves the generated key to the Android Keystore.
We can extract the SecretKey like this:

private fun secretKey(): SecretKey {
    val keyStore = KeyStore.getInstance("AndroidKeyStore")
    keyStore.load(null)
    val secretKeyEntry = keyStore.getEntry(keyAlias, null) as KeyStore.SecretKeyEntry
    return secretKeyEntry.secretKey
}

Now we can use the SecretKey to encrypt a ByteArray like this:

private fun encrypt(decryptedBytes: ByteArray): EncryptedData {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, secretKey())
    val ivBytes = cipher.iv
    val encryptedBytes = cipher.doFinal(decryptedBytes)
    return EncryptedData(ivBytes, encryptedBytes)
}

If you want to decrypt the data again, it works like this:

private fun decrypt(dataToDecrypt: EncryptedData): String {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    val spec = GCMParameterSpec(128, dataToDecrypt.iv)
    cipher.init(Cipher.DECRYPT_MODE, secretKey(), spec)
    return cipher.doFinal(dataToDecrypt.encryptedData)!!.contentToString()
}

This is an example, of how to encrypt data using symmetric keys. If you want to use asymmetric encryption, there is an API for that as well.

Data Transportation

HTTPS over SSL / TLS

Without an encrypted connection, everyone has access to the data sent over the network. A person who has access to just one of the routers between you and your target server can read everything that you send and receive. Therefore, you must encrypt your traffic: HTTPS is a must-have for any communication.

Android, by default, only allows HTTPS for connections. Your traffic is therefore encrypted using TLS (Transport Layer Security). Of course, if you really have to, you can still make exceptions for test cases and disable TLS.

Android API 20 by default has TLSv1.2 enabled, while TLSv1.3 is supported and enabled by default on Android API 29+ for client sockets.

SSL Pinning

By default, HTTPS connections are verified by the system which validates the server certificate and checks if it’s valid for this domain. This helps to make sure the connected server is not malicious, but there are still ways for attackers to perform more complex man-in-the-middle attacks.

The system certificate trust validation checks if the certificate was signed by a root certificate of a trusted certificate authority. To circumvent this security mechanism attackers would need to explicitly trust the malicious certificate in the user’s device settings or in the worst-case scenario compromise a certificate authority.

If someone would be able to use one of these attacks, he could then run a malicious server or perform a man-in-the-middle attack to read all messages sent between client and server. To prevent these kinds of attacks an app can perform their additional trust validation of the server certificates. This mechanism is called SSL or Certificate Pinning. You can implement this functionality by including a list of valid certificates (or its public keys or its hashes) in your app bundle. The app can, therefore, check if the certificate used by the server is on this list and only then communicate with the server.

Here is an example from the official Android documentation for implementing this in your app:

// Load CAs from an InputStream
// (could be from a resource or ByteArrayInputStream or ...)
val cf: CertificateFactory = CertificateFactory.getInstance("X.509")
// From https://www.washington.edu/itconnect/security/ca/load-der.crt
val caInput: InputStream = BufferedInputStream(FileInputStream("load-der.crt"))
val ca: X509Certificate = caInput.use {
    cf.generateCertificate(it) as X509Certificate
}
System.out.println("ca=" + ca.subjectDN)

// Create a KeyStore containing our trusted CAs
val keyStoreType = KeyStore.getDefaultType()
val keyStore = KeyStore.getInstance(keyStoreType).apply {
    load(null, null)
    setCertificateEntry("ca", ca)
}

// Create a TrustManager that trusts the CAs inputStream our KeyStore
val tmfAlgorithm: String = TrustManagerFactory.getDefaultAlgorithm()
val tmf: TrustManagerFactory = TrustManagerFactory.getInstance(tmfAlgorithm).apply {
    init(keyStore)
}

// Create an SSLContext that uses our TrustManager
val context: SSLContext = SSLContext.getInstance("TLS").apply {
    init(null, tmf.trustManagers, null)
}

// Tell the URLConnection to use a SocketFactory from our SSLContext
val url = URL("https://certs.cac.washington.edu/CAtest/")
val urlConnection = url.openConnection() as HttpsURLConnection
urlConnection.sslSocketFactory = context.socketFactory
val inputStream: InputStream = urlConnection.inputStream
copyInputStreamToOutputStream(inputStream, System.out)

Remote Notifications

You can send push notifications via the Firebase API. However, that opens up the possibility of Firebase misusing this data or Firebase accidentally leaking data.

Instead of exposing all notification contents to Firebase, you can simply use Firebase push notifications as a wakeup call for the phone to retrieve the data for the actual notification. Now, we can fetch the notification content from our secure server. This way, we are completely autonomous regarding information distribution, even if we want to use fast and reliable notifications from Firebase.

Best Practices to Secure Your Code

Securing our packaged code is also important. There is always the possibility of reverse engineering: someone might try to read how we do our encryption or find another loophole in our code.

Nothing stops an attacker from reading our compiled code. But, at the very least, we can make it as hard as possible for the attacker to gain any information from that code.

Static Code Analysis and Static Application Security Testing

Static code analysis acts as a preventive measure for us, to not write code riddled with security flaws. We want to be notified already at the point of writing our application, where potential security risks are and what we can do about them.

Android Studio provides us with Static Code Analysis via the tool called lint. To run it, simply run Analyze > Inspect Code in Android Studio.
The output gives you hints on correctness, performance, usability, data flow issues and also security issues.

It would e.g. warn you if you try to hardcode certain passwords in clear text in your app.

Code Obfuscation

Code obfuscation is a technique of transforming your source code into something that is difficult for humans to read. This is done mostly by automated tools before building the application.

This doesn’t make your code itself more secure. The only goal is to complicate the process of reverse-engineering your source code from a compiled application. Knowing how your source code works makes it vulnerable to certain hacker attacks.

Especially compiled Java-code can easily be decompiled into nicely readable Java-Source-Code. To make this, you can use R8, the new code optimizer and compiler for Android. It obfuscates and shrinks your compiled code. Decompiling the code afterwards will just spit out a messy version of your code without e.g. actual class or variable names.

Before R8, ProGuard used to be a standard for that. Here is a nice comparison between ProGuard and R8. Depending on what your focus lies on, you should choose your obfuscation framework.

When building your project with the Android Gradle plugin 3.4.0 or higher the R8 compiler is automatically used. You can switch back by setting android.enableR8 = false and android.enableR8.libraries=false in the gradle.properties or useProguard = true in the build.gradle file. This works only in Android Studio 3.4. With version 3.5 setting the useProguard flag has no effect.

Note also that R8 is backwards-compatible to ProGuard. So you can keep your existing ProGuard configuration files. Generally, the syntax between ProGuard and R8 stays the same. However, R8 provides some additional syntax on top.

We recommend R8 or ProGuard as a baseline for any application to make reverse engineering a little harder. For even better security and code protection, you can also try a commercial tool like DexGuard. It provides additional runtime security measures and improves code obfuscation.

Other Common Android App Security Risks

Third-party frameworks

3rd party SDKs or frameworks can be a huge security risk for your applications. Since they get compiled with your app and run in the same sandbox they have the same rights as your app. This means a malicious SDK can fetch your users’ location if you asked for this permission or even read from your application data storage or keychain. DON’T simply trust every library that you find on the internet.

Especially on Android, developers usually rely on many external libraries. Be aware that your application is not secure if any of these libraries is not secure.

Analytics

Less is more: especially in combination with third party analytics SDKs. Even if you implement your own analytics solution you should be careful which information you want to collect. Analytics data can often already be enough to identify users or to be able to access their data. E.g. an analytics framework recording your screen for crash reports can read your users’ login credentials.

The most popular solution for analytics on Android is Google Analytics. Include it at your own risk. If you use it, be aware that Google has access to all of the information that you log.

The only way to be fully on the safe side and be the real owner of your data is implementing your own Analytics solution.

Logging

Forgetting to remove log statements is something that happens to all of us. The problem arises if the logged stuff contains username, password or other sensitive information from the user. Sometimes you might not even do this on purpose. Imagine you would log the user’s clipboard content to the console. Your user then opens a password manager to copy & paste his password. That would mean, attackers can read your user’s clear text password in the console. Generally, all log statements should be checked carefully before the release of your app.

Using ProGuard or R8, you can remove log statements in release builds by adding this to the proguard-rules.pro file.

-assumenosideeffects class android.util.Log {
    public static boolean isLoggable(java.lang.String, int);
    public static int v(...);
    public static int d(...);
    public static int i(...);
}

Note that this will not remove logs about warnings and errors. If you want to be extra secure, you can remove the log statements for these as well.

Conclusion

Creating secure Android apps is tough, but there are ways to make your apps more resilient against attackers. Protecting user data must be a high priority and should never be ignored.

Especially on Android, your app will run on dozens of different mobile phones: each of them with a different hardware, firmware, Android version and software environment. Therefore, there is plenty of space for one element in the chain to break and to expose security holes. If you want to make sure that you users’ data is safe, you have to plan ahead for these security flaws in the operating system. Build a second line of defense by encrypting critical data if the operating system does not do so. Obfuscate your code, eliminate suspicious 3rd party libraries, and ensure that the connection to your remote server is secure.

With little effort, you can already make it much harder for an attacker to gain information from your app. You don’t want stolen passwords or other data leaks to destroy the success that you worked so hard for.

This article is the second one in the series of three articles where we talk about app security. In the last blog post, we have discussed app security more generally for both iOS and Android.

Are you an Android Developer?
Do you want to work with people that care about good software engineering?
Join our team in Munich

🐣

Get notified when our next article is born!

(no spam, just one app-development-related article per month)