Saturday, November 7, 2026
HomeDeveloper Corner, APIs & Reverse EngineeringHow to Implement Encrypted Local Storage Using Android Keystore

How to Implement Encrypted Local Storage Using Android Keystore

How to Implement Encrypted Local Storage Using Android Keystore

Local storage on a phone holds data that users trust an app to protect. Notes, tokens, health records, and messages may live in local files. A file stored in plain text can be read if the device is lost, or if another process gains access to it. Encryption turns that plain text into unreadable data without the right key. The key itself then becomes the thing that must be protected, and that is the problem Android Keystore exists to solve.

This guide shows how to build encrypted local storage with Android Keystore in a real app: creating an AES-GCM key, encrypting and decrypting values, handling keys that become invalid after a biometric change, rotating keys without losing data, and testing the whole flow on a real device. The code is written for Kotlin and Java apps targeting Android 6.0 and later, and every snippet is one you can adapt directly.

Why Local Data on Android Needs Encryption Even Inside the Sandbox

Android gives every app its own private directory, and other apps cannot normally read it. That sandbox is a strong first layer, but it is not the whole story. A lost or stolen phone can be examined offline. A rooted device can expose app files. A backup can carry files somewhere unexpected. Encryption adds a second layer that holds even when the first layer fails: without the key, the file is noise.

What Keystore Does and Does Not Protect

Keystore protects the key, not the app. An app can ask the Keystore to encrypt or decrypt data with a key, but it cannot read the key value itself. A leaked database file alone therefore does not expose user data, because the attacker would also need the device and the app context to use the key. What Keystore cannot do is stop a compromised device from using the key while the device is in active use. Design for the offline attacker, and handle the active-device case with authentication and good session practice instead.

The sandbox model around all of this is explained in the guide to Android application sandboxing. Encryption and sandboxing are two layers of the same defence, not alternatives.

How Android Keystore Keys Differ From Keys Stored in Your App

A key stored in SharedPreferences or in a file is just data. Anyone who reads that file can use the key, which means the encryption is only as strong as the file protection. A Keystore key lives in a secure area, in hardware on modern devices or in a protected system service on older ones. The app receives a reference to the key rather than the key bytes, and that reference lets the app use the key for crypto operations without ever seeing the key itself.

Hardware-backed keys offer the strongest protection: the key material never leaves the secure element or the trusted execution environment, so it cannot be extracted by software alone. A software-backed Keystore key still benefits from system protection but is weaker. Check the key information during development to learn which type your test device provides, and never assume hardware backing on every device.

Key Properties Act as a Policy

Key properties control how a key can be used, and they turn a simple key into a policy that matches the sensitivity of the data. A key can require user authentication before each use. A key can be limited to encryption only or decryption only. A key can become invalid when the user adds a new fingerprint or changes the lock screen. Set these properties at creation time, because they cannot be changed later: changing a property means creating a new key, and a new key cannot decrypt data written with the old one.

Designing a Two-Level Encryption Scheme for Local Storage

The standard design uses a Keystore key to protect a random data key. The data key encrypts the actual files or database values, and the Keystore key encrypts the data key for storage. That two-level design lets you rotate the data key without changing the Keystore key, and it lets you encrypt large data efficiently with a fast symmetric cipher.

The Data Key Pattern

Generate the data key once, as a random 256-bit AES key, using the normal KeyGenerator without the Keystore provider. Wrap it with the Keystore key and store the wrapped bytes in a private file or in SharedPreferences. On app start, unwrap the data key through the Keystore and keep it in memory. Encrypt and decrypt with the data key from then on.

Choosing an Authenticated Cipher Mode

Choose authenticated encryption for data. Authenticated encryption detects tampering as well as hiding content, so a modified ciphertext fails decryption instead of silently producing wrong data. AES in GCM mode is the modern choice on Android: it combines encryption and authentication in one operation, it is supported by the Keystore on Android 6.0 and later, and it needs no manual authentication step. Use it rather than an older mode such as CBC, which needs a separate message authentication code that is easy to get wrong.

Creating an AES-GCM Key With KeyGenParameterSpec

Create the Keystore key once, when the app first needs encrypted storage. Set a clear alias name for the key, set the purposes the key will serve, and set the block mode and padding that match your chosen cipher. The snippet below is the exact code for an AES key that encrypts and decrypts with GCM and no padding, which is the correct combination for this mode.

KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder(
        "app_data_key",
        KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setRandomizedEncryptionRequired(true)
        .build();
KeyGenerator generator = KeyGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
generator.init(spec);
generator.generateKey();

What Each Setting in the Code Does

The alias “app_data_key” is the name your app uses to find the key later, so keep alias names stable across app updates. A changed alias creates a new key and old data becomes unreadable, so treat an alias name as part of your storage format and document it in the app design notes. The block mode GCM and padding NONE belong together, and the Keystore will reject combinations that do not make sense, so a mistake here fails loudly at creation time rather than silently at runtime.

Why setRandomizedEncryptionRequired Matters

GCM mode needs a fresh random initialisation vector for every encryption, and setRandomizedEncryptionRequired(true) tells the Keystore to enforce that. Without it, a caller could pass a fixed or reused vector, which weakens GCM badly: a reused vector in GCM can leak the authentication key and destroy the security of every value encrypted with that key. Keep the flag on, let the cipher generate the vector itself, and never supply your own fixed vector. The vector itself is not secret, but it must be unique for each encryption, and the safest way to guarantee that is to let the platform generate it.

Encrypting and Decrypting Data With the Keystore Key

With the key created, the encrypt and decrypt calls are short. The only part that needs care is the initialisation vector: the cipher generates a fresh 12-byte vector on every encryption, and decryption needs that exact vector. Store the vector beside the ciphertext in a format your code can parse later, usually by writing the vector first and then the ciphertext in a single byte array.

public static byte[] encrypt(SecretKey key, byte[] plain) throws Exception {
    Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(Cipher.ENCRYPT_MODE, key);
    byte[] iv = cipher.getIV();
    byte[] cipherText = cipher.doFinal(plain);
    ByteBuffer buffer = ByteBuffer.allocate(iv.length + cipherText.length);
    buffer.put(iv);
    buffer.put(cipherText);
    return buffer.array();
}
public static byte[] decrypt(SecretKey key, byte[] data) throws Exception {
    ByteBuffer buffer = ByteBuffer.wrap(data);
    byte[] iv = new byte[12];
    buffer.get(iv);
    byte[] cipherText = new byte[buffer.remaining()];
    buffer.get(cipherText);
    Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    GCMParameterSpec spec = new GCMParameterSpec(128, iv);
    cipher.init(Cipher.DECRYPT_MODE, key, spec);
    return cipher.doFinal(cipherText);
}

Encrypting a Small Value Directly

To encrypt a short string such as a token, load the Keystore key by its alias, convert the string to bytes with UTF-8, call the encrypt helper, and store the returned bytes, usually encoded with Base64 so they fit in SharedPreferences or a text column. Test decryption immediately after encryption in development to catch format errors early. A round-trip test in a unit test or in the app’s debug screen catches a wrong IV layout or a wrong tag length before users ever see the code.

Encrypting Files and Database Values With a Data Key

For larger data, use the two-level design. Generate a random AES data key, wrap it with the Keystore key, and store the wrapped key. Then encrypt files or database values with the data key using the same helpers. A database column is encrypted per value, so each row carries its own vector and ciphertext, and a query on the encrypted column is no longer possible, which is a trade-off to accept consciously.

Encrypted database values still need fast queries, and that topic is covered by Room database indexing optimization. The practical rule is to encrypt the sensitive columns and keep the columns you search on, such as a row id or a timestamp, in plain form. That keeps the index usable while the personal content stays encrypted.

Storing the IV Beside the Ciphertext

The initialisation vector is 12 bytes for GCM, and the decrypt helper above reads exactly 12 bytes from the front of the stored data. That fixed layout is the simplest format that works: vector first, ciphertext second, no header, no separator. If you ever change the format, version it, because old data written with the old layout must still decrypt. A single version byte at the front of the stored data is enough, and it turns a breaking change into a supported migration.

Handling Key Invalidation After Biometric and Lock Screen Changes

A key may become invalid after a biometric change. When the user adds a new fingerprint, or changes the lock screen, a key that requires biometric authentication can be permanently invalidated, and the app can no longer decrypt old data with it. The Keystore throws KeyPermanentlyInvalidatedException in that case, and the app must handle it with a user-friendly recovery path instead of a silent crash.

Rotating Keys Without Losing User Data

Key rotation scheduling should be part of the app maintenance plan. Rotate the data key at least once a year, or after a major security incident. A data key rotation re-encrypts stored data with a new data key and protects that key with the same Keystore key, so the Keystore key itself never changes. That is the easy rotation, and it is the reason the two-level design exists: it makes rotation a background job instead of a migration crisis.

A Keystore key rotation is harder, because old data needs the old key until it is re-encrypted. Keep the old key available during a migration window and delete it only after migration succeeds. In practice that means creating the new Keystore key under a new alias, re-encrypting every stored value from the old key to the new one, and only then removing the old key.

Data Key Rotation Step by Step

  1. Generate a new data key and wrap it with the same Keystore key.
  2. Read each stored value, decrypt it with the old data key, encrypt it with the new one, and write it back.
  3. Update the stored wrapped key only after every value has been re-encrypted.
  4. Run the job in the background with WorkManager so it survives app restarts, and make it resumable so an interrupted run finishes the remaining values.

Testing Encrypted Storage on Real Devices

Test encryption on a real device with a lock screen set. Test with biometric authentication enabled and disabled. Test after adding a new fingerprint if your key requires biometric authentication. Each test reveals a different invalidation or authentication behaviour, and a test on an emulator alone may not reveal hardware-backed key behaviour, because the emulator Keystore is software-backed.

Encryption choices should be verified by the audit routine described in the guide to application security audits. Static analysis can catch a hard-coded key or a weak cipher mode, and dynamic analysis can confirm that no plaintext is written to disk, to logs, or to a backup. Run both before every release that touches storage code.

The Device Test Checklist

Work through this list on a real device. Create a key and encrypt one value, then decrypt it immediately. Lock the device, wait, and decrypt again. Restart the device and open the app without opening any other app first, then confirm that decryption still works and that the biometric prompt appears at the right moment. Add a new fingerprint and confirm that the invalidation path triggers cleanly. Turn biometric authentication off and confirm the app falls back to the lock screen prompt. Record the result of each test in your notes alongside the restart result, because both tests together cover the main key lifecycle events.

Upgrade and Restart Tests

Test upgrade paths carefully. Install an old version and create encrypted data, then update to the new version and confirm that decryption still works. An upgrade test catches a key alias change or a format change before users suffer data loss. Also test on a device where the user has never set a lock screen at all, because key creation can behave differently there. Trigger biometric authentication only when the app truly needs to decrypt sensitive data, and explain in plain language why authentication is needed for that action.

Frequently Asked Questions About Encrypted Storage on Android

Does Keystore encryption protect data when the phone is in active use?

Not fully. When the device is in active use and the app runs normally, the app can decrypt its own data by design, and so can anything running with the app’s identity. Keystore protects data at rest and against offline extraction. It does not protect data from the app itself while it runs, so pair it with good session handling and prompt authentication for the most sensitive actions.

What happens if a user adds a new fingerprint?

A key that requires biometric authentication may become invalid after a biometric enrolment change, and the Keystore throws KeyPermanentlyInvalidatedException when the app tries to use it. The app must handle that case with a clear recovery path, such as erasing local data or restoring from a backup.

Should I encrypt every file in my app?

Encrypt sensitive data such as tokens and personal records. Encrypting every cache file adds complexity without much benefit, and it slows down work that has no real risk attached to it. Focus encryption effort on data that would harm a user if it leaked from a lost device, and leave caches, thumbnails, and other replaceable data in plain form.

Android Keystore turns key protection from a hard problem into a managed service. Hardware-backed keys, clear key properties, and authenticated encryption together protect local data at rest. A two-level design with a data key and a Keystore key gives flexibility for rotation, and testing on real devices reveals invalidation behaviour before users face it.

Your concrete next step is to create a Keystore key in your own test app today and encrypt one small sensitive value with it. Decrypt that value after a restart and confirm the round trip. That exercise proves the core flow before you encrypt a full database, and it takes less than an hour.

RELATED ARTICLES

Most Popular