Data Storage Security Explained

SECURITY › Security

Start with the threat model, because it explains every API in this topic: on-device data is **attacker-reachable**. A rooted device, a stolen phone with an unlocked bootloader, or a forensic extraction tool can all read past ordinary app-private file permissions. "It's in my app's private directory" is a filesystem boundary, not a security boundary.

Android's answer is the **Android Keystore system**. When you generate a key in the Keystore, the raw key material is created inside secure hardware (a Trusted Execution Environment or a dedicated secure element) and never leaves it. Your app can ask the Keystore to encrypt or decrypt using that key, but it can never read the key bytes out, even from a fully compromised process.

That one guarantee, **keys are usable but non-exportable**, is the foundation everything else in this lesson builds on.

The most common place you'll apply that guarantee is EncryptedSharedPreferences. It wraps ordinary SharedPreferences and encrypts every entry, but keys and values use **different schemes** for a reason.

Preference **keys** are encrypted with **AES256-SIV**, a deterministic mode: the same plaintext key always produces the same ciphertext. That determinism is required so lookups still work, you need "auth_token" to map to the same encrypted key every time you read it.

Preference **values** use **AES256-GCM**, a randomized, authenticated mode: a fresh IV each time means the same value encrypts differently on every write, and any tampering is detected on read. Both are backed by a MasterKey that itself lives in the Android Keystore, so the encryption key is never sitting in plaintext anywhere on disk.

Plain SharedPreferences is just an unencrypted XML file sitting in app-private storage. That's fine for a theme preference or a feature flag. It is **never** fine for anything an attacker could use: auth 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 by ADB backup or a device forensic image. The fix isn't a different file format, it's encryption tied to a key that can't leave the device: EncryptedSharedPreferences, or a value you encrypt yourself with a Keystore-backed Cipher before writing it anywhere.

You can also gate a key behind biometrics, so it's only usable right after the user proves who they are. Set setUserAuthenticationRequired(true) on the key, plus setUserAuthenticationParameters(timeout, AUTH_BIOMETRIC_STRONG).

A timeout of 0 means **per-operation** authentication: the key is authorized for exactly one cryptographic operation, then locks again. You wrap your Cipher in a BiometricPrompt.CryptoObject so the prompt itself unlocks that specific operation:

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)
    .build()

A nonzero timeout instead gives you a *window* (say, 60 seconds) where the key stays usable without re-prompting, a tradeoff between convenience and how long an unlocked key stays exposed.

There's a separate lock worth knowing, distinct from per-operation biometric gating: setUnlockedDeviceRequired(true). Where setUserAuthenticationRequired ties a key to a *recent* auth event, this one ties it to the device's *current* lock state, the key simply refuses to operate while the screen is locked, no matter how recently the user unlocked it before that.

That closes a real gap: a background service or a BroadcastReceiver running while the phone sits locked on a nightstand shouldn't be able to decrypt anything, even if the user authenticated an hour ago and the auth validity window hasn't expired. Any attempt to use the key while locked throws a UserNotAuthenticatedException instead of succeeding.

val spec = KeyGenParameterSpec.Builder("unlocked_key", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
    .setBlockModes(BLOCK_MODE_GCM)
    .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
    .setUnlockedDeviceRequired(true)
    .build()

One more gotcha with biometric-bound keys: by default, invalidatedByBiometricEnrollment is **true**. If the user (or an attacker holding the unlocked device) enrolls a *new* fingerprint or face, the key is permanently invalidated, it simply stops working, even for the legitimate owner's original biometric.

This is deliberate. It defends against an attacker who gets hold of an unlocked device and adds their own biometric to gain standing access to a biometric-gated key. The cost is a real usability hit: legitimate re-enrollment (a new fingerprint after an injury, say) breaks the key too, and you have to handle regenerating it.

You can opt out with setInvalidatedByBiometricEnrollment(false), but that reopens the exact attack the default is closing.

Not all Keystore-backed hardware is equal. Most devices run keys in a **TEE** (Trusted Execution Environment), an isolated region of the main processor. Some devices also offer **StrongBox**: a physically separate secure element with its own CPU, storage, and random number generator, request it with setIsStrongBoxBacked(true).

StrongBox is more tamper-resistant because it's a discrete chip an attacker would have to physically extract and attack on its own, rather than a region of the main SoC. The tradeoff is real: StrongBox is slower, supports fewer algorithms, and has tighter limits on concurrent operations, so it's reserved for genuinely high-value keys rather than used everywhere by default.

**Scoped storage** (the default since Android 10 or 11) applies the same "don't trust broad access" philosophy to files, not just keys. Apps no longer get a blanket READ/WRITE_EXTERNAL_STORAGE grant to wander the whole shared filesystem. Instead, you're limited to your own app-private directories, plus MediaStore for media your app owns or has been granted access to.

For anything outside that, an arbitrary PDF the user wants to import, say, you use the **Storage Access Framework**: an intent like ACTION_OPEN_DOCUMENT opens the system file picker, and the user's selection comes back as a scoped content:// URI your app is granted access to, with no storage permission declared at all.

Last piece, and one that trips people up in 2026 interviews: androidx.security:security-crypto, the library behind EncryptedSharedPreferences, was **deprecated in 2024** and is no longer maintained. It still runs, but it gets no further fixes.

The recommended paths now are calling the **Android Keystore directly** (generate a key, drive a Cipher yourself) or using **Tink**, Google's crypto library, with an AndroidKeysetManager whose keyset is wrapped by a Keystore master key, so you get misuse-resistant AEAD without the deprecated wrapper or hand-rolled crypto:

val keysetHandle = AndroidKeysetManager.Builder()
    .withSharedPref(context, "tink_keyset", "tink_keyset_prefs")
    .withKeyTemplate(AeadKeyTemplates.AES256_GCM)
    .withMasterKeyUri("android-keystore://tink_master_key")
    .build().keysetHandle

Either way, the core idea from the start of this lesson hasn't moved: encrypt sensitive data with a key that never leaves secure hardware.

Sometimes it's not enough for *your app* to trust that a key is hardware-backed, a backend needs cryptographic proof of it too, say, before trusting a device in a high-value flow. That's **key attestation**: generate the key with setAttestationChallenge(nonce), where the nonce comes from your server 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 CA, and the leaf certificate's extension data reveals the key's security level: whether it's genuinely inside a TEE or StrongBox, non-exportable, and generated with the parameters you expect. A boolean the client sends itself, isInsideSecureHardware = true, proves nothing, a compromised app can just lie. The signed chain is what makes the claim actually verifiable server-side.

val spec = KeyGenParameterSpec.Builder("attested_key", PURPOSE_SIGN)
    .setDigests(DIGEST_SHA256)
    .setAttestationChallenge(nonce)
    .build()

A common 2026-era mistake: migrating from SharedPreferences to Jetpack DataStore and assuming the move itself adds security. It doesn't. DataStore (Preferences or Proto) solves the threading and consistency problems SharedPreferences has, atomic writes, no blocking main-thread reads, a Flow-based API, but it stores its file **in plaintext** on disk by default, exactly like the API it replaces. A binary protobuf format isn't a security boundary any more than XML was.

So the same rule from earlier in this lesson still applies: anything sensitive, tokens, passwords, keys, needs to be encrypted with a Keystore-backed Cipher *before* it's written into DataStore, not left to the storage layer to handle for you.

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

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

The good news: Keystore-backed keys default to randomizedEncryptionRequired = true, so the system generates a fresh random IV on every Cipher.init() call for you. You just have to remember to store that IV alongside the ciphertext, you'll need it to decrypt later.

cipher.init(Cipher.ENCRYPT_MODE, key) // fresh random IV generated automatically
val iv = cipher.iv // store alongside the ciphertext

One last question interviewers like: 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? No. Keystore keys are non-exportable by definition, they never leave the secure hardware they were generated in, so they are never included in any backup. The restored ciphertext lands on the new device with no key able to open it, it's just noise.

That said, defense in depth still matters: exclude sensitive files from auto-backup explicitly with data_extraction_rules.xml, so encrypted blobs, and any accidental plaintext secret, never leave the device via backup in the first place.

<data-extraction-rules>
    <cloud-backup>
        <exclude domain="sharedpref" path="secret_prefs.xml"/>
    </cloud-backup>
</data-extraction-rules>

Back to Data Storage Security