Data Storage Security Explained

SECURITY › Security

Every question in this lesson traces back to one threat model: data at rest on an Android device is attacker-reachable. A rooted phone, a stolen device with an unlocked bootloader, or a forensic extraction tool can all read straight past ordinary app-private file permissions. Saying a file lives in your app's private directory describes a filesystem boundary, not a security boundary, so anything valuable needs an actual cryptographic defense.

Android's answer is the Android Keystore system. When you generate a key inside the Keystore, the raw key material is created inside secure hardware, either a Trusted Execution Environment or a dedicated secure element, and it never leaves. Your app can ask the Keystore to run an encrypt or decrypt operation with that key, but it can never read the key bytes back out, not even from a process an attacker fully controls. Calling key.encoded on a Keystore key always returns null.

That one property, keys are usable but non-exportable, is what every other feature in this lesson leans on. It is why encrypting with a Keystore-backed key is fundamentally different from encrypting with a key you generated and hold in your own process.

val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val key = keyStore.getKey("my_key", null) as SecretKey

val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val ciphertext = cipher.doFinal(plaintext)

println(key.encoded) // null, key material never leaves the Keystore

The most common place you will apply that non-exportable guarantee is EncryptedSharedPreferences. It wraps ordinary SharedPreferences and encrypts every entry, but it treats keys and values differently on purpose.

Preference keys are encrypted with AES256-SIV, a deterministic scheme. Deterministic means the same plaintext key always produces the same result, which matters because you still need to look values up by key. If encrypting the string auth token produced something different every time, you could never find the entry again.

Preference values, on the other hand, use AES256-GCM, a randomized and authenticated scheme. A fresh initialization vector each time means the same value looks different on disk on every write, and any tampering with the stored bytes is detected on read. Both schemes are ultimately backed by a MasterKey, and that MasterKey itself lives in the Android Keystore, so the encryption key protecting your preferences file is never sitting in plaintext anywhere on disk.

val prefs = EncryptedSharedPreferences.create(
    context,
    "secret_prefs",
    MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,   // deterministic
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM  // randomized
)

Plain SharedPreferences is just an unencrypted XML file sitting in app-private storage. That is fine for a theme setting or a feature flag. It is never acceptable for anything an attacker could actually use: authentication tokens, refresh tokens, passwords, API keys, session identifiers, or other personal data.

On a rooted device that XML file is trivially readable. It can also be swept up whole by an ADB backup or a device forensic image, neither of which cares about your app's file permissions. The fix is not a different file format, a binary file is no safer than XML by itself. The fix is encryption tied to a key that cannot leave the device, whether that is EncryptedSharedPreferences or a value you encrypt yourself with a Keystore-backed cipher before writing it anywhere.

// BAD: refresh token stored as plaintext, readable on rooted devices and in backups
sharedPrefs.edit().putString("refresh_token", token).apply()

// GOOD: encrypt with a Keystore-backed cipher before storing
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, keystoreKey)
val iv = cipher.iv
val encrypted = cipher.doFinal(token.toByteArray(Charsets.UTF_8))
// Store iv plus encrypted bytes, the raw token never touches SharedPreferences

You can also gate a Keystore key behind biometrics, so it is only usable in the instant after the user proves who they are. Two builder calls do this: setUserAuthenticationRequired(true), and setUserAuthenticationParameters, which takes a timeout and an authenticator type such as biometric strong.

A timeout of zero seconds means per-operation authentication. The key is authorized for exactly one cryptographic operation and then locks again immediately. To actually unlock that one operation, you wrap your Cipher in a BiometricPrompt dot CryptoObject and hand it to the prompt, so a successful fingerprint or face check authorizes that specific Cipher instance, not the key in general.

A nonzero timeout instead opens a window, say sixty seconds, where the key stays usable without re-prompting. That is a real tradeoff between convenience and how long an unlocked key stays exposed if the device is grabbed right after authentication.

val spec = KeyGenParameterSpec.Builder("bio_key", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
    .setBlockModes(BLOCK_MODE_GCM)
    .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)
    .setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) // 0 = per-operation
    .build()

val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
    init(Cipher.ENCRYPT_MODE, keyStore.getKey("bio_key", null) as SecretKey)
}
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))

There is a sharp gotcha with biometric-bound keys that catches people out: by default, invalidatedByBiometricEnrollment is true. If the user, or an attacker holding an unlocked device, enrolls a new fingerprint or face, the key is permanently invalidated. It stops working entirely, even for the legitimate owner's original biometric.

This is deliberate, not a bug. It defends against an attacker who gets a device unlocked, maybe grabbed while the owner was using it, and adds their own biometric to gain standing access to a biometric-gated key going forward. The real cost is a usability hit: a legitimate re-enrollment, a new fingerprint after an injury, say, breaks the key too, and your app has to detect that and regenerate it. You can opt out with setInvalidatedByBiometricEnrollment(false), but doing so reopens exactly the attack the default is closing.

val spec = KeyGenParameterSpec.Builder("bio_key", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
    .setBlockModes(BLOCK_MODE_GCM)
    .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)
    // Default true: any new biometric enrollment permanently invalidates this key
    .setInvalidatedByBiometricEnrollment(true)
    .build()

There is a separate lock worth knowing, distinct from biometric gating: setUnlockedDeviceRequired(true). Where setUserAuthenticationRequired ties a key to a recent authentication event, this ties the key to the current lock state. The key simply refuses to operate while the screen is locked, no matter how recently the user authenticated before that.

That closes a real gap. A background service or a broadcast receiver running while the phone sits locked on a nightstand should not be able to decrypt anything, even if the user unlocked and authenticated an hour earlier and the authentication validity window has not technically expired. Any attempt to use the key while the device is locked throws a UserNotAuthenticatedException instead of quietly succeeding.

val spec = KeyGenParameterSpec.Builder("unlocked_key", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
    .setBlockModes(BLOCK_MODE_GCM)
    .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
    .setUnlockedDeviceRequired(true) // use while locked throws UserNotAuthenticatedException
    .build()

Not all Keystore-backed hardware is the same. Most devices run keys inside a Trusted Execution Environment, an isolated region carved out of the main system on a chip. Some devices also offer StrongBox: a physically separate secure element with its own CPU, its own storage, and its own random number generator, requested with setIsStrongBoxBacked(true).

StrongBox is more tamper-resistant precisely because it is a discrete chip an attacker would have to physically desolder and attack on its own, rather than a region carved out of the main processor that runs everything else too. The tradeoff is real, though: StrongBox is slower, supports fewer algorithms, and has tighter limits on how many operations can run concurrently. That is why it gets reserved for genuinely high-value keys instead of being the default for everything.

if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)) {
    val spec = KeyGenParameterSpec.Builder("sb_key", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
        .setBlockModes(BLOCK_MODE_GCM)
        .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
        .setIsStrongBoxBacked(true)
        .build()
    KeyGenerator.getInstance(KEY_ALGORITHM_AES, "AndroidKeyStore")
        .apply { init(spec) }.generateKey()
}

Scoped storage, the default since Android 10 or 11, applies that same don't-trust-broad-access philosophy to files, not just keys. Apps no longer get a blanket grant to wander the entire shared filesystem. You are limited to your own app-private directories, plus MediaStore for media your app owns or has been granted access to.

For anything outside that, importing an arbitrary PDF the user picks from anywhere on the device, say, you use the Storage Access Framework. An intent like ACTION_OPEN_DOCUMENT opens the system file picker, and whatever the user selects comes back as a scoped content URI your app is granted access to. No broad storage permission gets declared at all, and the all-files MANAGE_EXTERNAL_STORAGE permission is reserved for a narrow set of app categories, not ordinary apps.

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "application/pdf"
}
startActivityForResult(intent, RC_OPEN_PDF)

// In onActivityResult:
val uri = data?.data ?: return
contentResolver.openInputStream(uri)?.use { stream -> /* read PDF bytes */ }

One more piece that trips people up in interviews right now: androidx.security colon security-crypto, the library behind EncryptedSharedPreferences, was deprecated in 2024 and is no longer maintained. It still runs if it is already in your project, but it receives no further fixes, so it is not the right choice for new code.

The recommended paths today are either calling the Android Keystore directly, generating a key and driving a Cipher yourself, or using Tink, Google's crypto library, through an AndroidKeysetManager whose keyset is wrapped by a Keystore-backed master key. Tink gives you misuse-resistant authenticated encryption without hand-rolling crypto and without depending on the deprecated wrapper.

// PREFERRED: Google's Tink library with an AndroidKeysetManager-managed keyset
val keysetHandle = AndroidKeysetManager.Builder()
    .withSharedPref(context, "tink_keyset", "tink_keyset_prefs")
    .withKeyTemplate(AeadKeyTemplates.AES256_GCM)
    .withMasterKeyUri("android-keystore://tink_master_key") // wrapped by Keystore
    .build()
    .keysetHandle
val aead = keysetHandle.getPrimitive(Aead::class.java)

A mistake specific to this era: migrating from SharedPreferences to Jetpack DataStore and assuming the migration itself adds security. It does not. DataStore, whether Preferences or Proto, fixes real problems SharedPreferences has: atomic writes, no blocking main-thread reads, a Flow-based API. But by default its file sits in plaintext on disk, exactly like the API it replaces. A binary protobuf format is not a security boundary any more than XML ever was.

So the rule from earlier in this lesson still applies unchanged: anything sensitive, a token, a password, a key, needs to be encrypted with a Keystore-backed cipher before it is written into DataStore, not left to the storage layer to handle for you.

val Context.dataStore by preferencesDataStore(name = "settings")

context.dataStore.edit { prefs ->
    prefs[stringPreferencesKey("oauth_token")] = sensitiveToken // NOT encrypted on disk!
}

// For secrets: encrypt with a Keystore-backed cipher BEFORE writing to DataStore

If you ever hand-roll AES-GCM yourself instead of leaning on EncryptedSharedPreferences or Tink, there is one rule that is not optional: never reuse an initialization vector with the same key. GCM's security guarantee depends on every key and IV pair being unique. Reuse one, and an attacker who sees two ciphertexts encrypted under that pair can recover the combination of the two plaintexts, and worse, recover the authentication subkey itself, which then lets them forge valid ciphertexts for that key going forward. That is not a minor bug, it is a full break of both confidentiality and integrity for that key.

The good news is that Keystore-backed keys default to randomizedEncryptionRequired equals true, so the system generates a fresh random IV on every Cipher dot init call for you automatically. You just have to remember to store that IV alongside the ciphertext, since you need it again to decrypt later.

val cipher = Cipher.getInstance("AES/GCM/NoPadding")

// BAD: same IV reused with the same key, catastrophic
val fixedIv = ByteArray(12) // all-zero, reused every call
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, fixedIv))

// GOOD: let the Keystore supply a fresh random IV per operation
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv // store alongside the ciphertext

One last question interviewers like to ask: if a device with Keystore-encrypted secrets gets backed up to the cloud and restored onto a brand new phone, can a thief who steals that backup decrypt anything inside it? No. Keystore keys are non-exportable by definition, the same guarantee from the start of this lesson, so they never leave the secure hardware they were generated in and are never included in any backup. The restored ciphertext lands on the new device with no key able to open it, it is just noise.

That said, defense in depth still matters. You should still exclude sensitive files from auto-backup explicitly using a data extraction rules file, so encrypted blobs, and any accidentally-plaintext secret, never leave the device through backup in the first place.

<!-- res/xml/data_extraction_rules.xml (Android 12+) -->
<data-extraction-rules>
    <cloud-backup>
        <exclude domain="sharedpref" path="secret_prefs.xml"/>
    </cloud-backup>
</data-extraction-rules>

Sometimes it is not enough for your own app to trust that a key is hardware-backed, a backend needs cryptographic proof of that too, before trusting a device in a high-value flow like a payment or an enrollment. That is what key attestation is for. You generate the key with setAttestationChallenge, passing a nonce your server generated to prevent replay, and the Keystore returns a certificate chain rooted in a Google attestation key.

Your server verifies that chain against Google's attestation root certificate authority, and the leaf certificate's extension data reveals the key's actual security level: whether it genuinely sits inside a TEE or StrongBox, whether it is non-exportable, and whether it was generated with the parameters you expect. A boolean the client sends itself, something like isInsideSecureHardware equals true, proves nothing, since a compromised app can simply lie about it. The signed certificate chain is what makes the claim actually verifiable on the server side.

val nonce: ByteArray = getServerNonce() // prevents replay
val spec = KeyGenParameterSpec.Builder("attested_key", PURPOSE_SIGN)
    .setDigests(DIGEST_SHA256)
    .setAttestationChallenge(nonce) // triggers hardware attestation cert chain
    .build()
KeyPairGenerator.getInstance(KEY_ALGORITHM_EC, "AndroidKeyStore")
    .apply { initialize(spec) }.generateKeyPair()

val chain = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    .getCertificateChain("attested_key")
    .map { (it as X509Certificate).encoded }
// Server verifies chain against Google attestation root CA

Back to Data Storage Security