Protecting Encryption Keys with Android Keystore
As Android engineers, storing data locally is something we do all the time.
Sometimes it’s as simple as:
sharedPreferences.edit()
.putString("access_token", token)
.apply()
It works.
It’s simple.
And for many types of non-sensitive application data, that might be completely fine.
But things become different when the value we’re storing is sensitive.
Imagine our SharedPreferences containing something like:
access_token = eyJhbGciOiJIUzI1Ni...
user_id = 123456
The application can read it.
But if someone manages to gain access to the application’s local storage, they may also be able to read it.
That led me to a simple question:
What if we encrypt the value before storing it?
Encrypting the Value
The first approach seems straightforward.
Instead of storing:
Plaintext
↓
SharedPreferences
we encrypt it first:
Plaintext
↓
AES-GCM
↓
Ciphertext
↓
SharedPreferences
Now if someone looks at the stored value, instead of seeing:
access_token = eyJhbGciOiJIUzI1Ni...
they would see something closer to:
access_token = Ax82mK9xP0...==
Much better.
But then another question appears:
Where do we store the encryption key?
The Encryption Key Problem
To encrypt and decrypt the data, we need a cryptographic key.
A naive implementation might define that key somewhere inside the application.
Conceptually:
private const val SECRET_KEY = "my-secret-encryption-key"
And use it like:
SECRET_KEY
↓
AES-GCM
↓
Encrypted Data
At first glance, this solves the local storage problem.
The sensitive value is no longer stored as plaintext.
But we’ve actually introduced another problem.
Our encryption key is now part of the application.
Android applications are distributed as APKs, and APKs can be inspected, decompiled, and reverse engineered.
Obfuscation can make that process harder, but it doesn’t turn a hardcoded secret into a securely protected secret.
So we end up moving the problem:
Sensitive Data
↓
Plaintext in Local Storage
becomes:
Sensitive Data
↓
Encrypted Data
BUT
Encryption Key
↓
Hardcoded in the Application
The data is encrypted, but the key needed to decrypt it is still shipped with the application.
That’s when I started looking for a better way to manage encryption keys on Android.
And that’s where Android Keystore comes in.
What Is Android Keystore?
Android provides the Android Keystore System specifically for managing cryptographic keys.
Instead of creating an encryption key and storing its raw value ourselves, we can ask Android Keystore to generate it for us.
Conceptually:
Application
│
│ Generate Key
↓
Android Keystore
│
│ SecretKey
↓
AES-GCM
│
↓
Encrypted Data
The important difference is that the application doesn’t need to store the raw cryptographic key in source code, SharedPreferences, or another regular application file.
Instead, we identify the key using an alias.
For example:
local_storage_key
That alias is not the encryption key itself.
Think of it more like an identifier that allows us to ask Android Keystore:
“Give me access to the cryptographic key associated with this alias.”
The actual key material remains managed by the Keystore.
Generating a Key with Android Keystore
For example, we can generate an AES key like this:
private const val KEY_ALIAS = "local_storage_key"
private fun generateKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or
KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(
KeyProperties.ENCRYPTION_PADDING_NONE
)
.build()
keyGenerator.init(keyGenParameterSpec)
return keyGenerator.generateKey()
}
Notice something interesting here.
We never write something like:
val secretKey = "super-secret-key"
Instead:
KeyGenerator
↓
Android Keystore
↓
Generate AES Key
↓
SecretKey
Android handles the cryptographic key for us.
Getting the Key Back
When we need the key again, we don’t read its raw value from SharedPreferences.
We load the Android Keystore:
private fun getKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
}
Again, what we’re getting here is a SecretKey object that can be used for cryptographic operations.
We’re not maintaining something like:
SECRET_KEY = "abc123..."
inside our application.
That changes the responsibility quite significantly.
Instead of:
Application
│
├── Encryption Key
├── Encryption Logic
└── Encrypted Data
we move toward:
Android Keystore
│
│ SecretKey
↓
Application ───→ AES-GCM
│
↓
Encrypted Data
│
↓
Local Storage
The application handles the encryption operation.
Android Keystore handles the cryptographic key.
Is Android Keystore Completely Unhackable?
No.
And I think this distinction is important.
Using Android Keystore doesn’t magically make an application impossible to attack.
If the device or application process is compromised, there are still attack scenarios we need to consider.
What Android Keystore gives us is a much better place to manage cryptographic keys compared with embedding raw key material directly inside the application.
Depending on the Android version, device capabilities, and how the key is configured, Keystore keys may also be backed by secure hardware such as a Trusted Execution Environment (TEE) or StrongBox.
The important point isn’t:
“The encryption key can never be compromised.”
It’s:
We no longer need to ship or persist the raw encryption key ourselves.
And that removes an entire class of mistakes around hardcoded cryptographic keys.
What Changed in the Design?
Before:
Hardcoded Encryption Key
│
↓
AES-GCM
│
↓
Encrypted Value
│
↓
SharedPreferences
After introducing Android Keystore:
Android Keystore
│
│ SecretKey
↓
AES-GCM
│
↓
Encrypted Value
│
↓
SharedPreferences
It might look like a small architectural change.
But it changes an important security assumption.
The application no longer needs to know or persist the raw cryptographic key itself.
Lesson Learned
One thing I learned from exploring Android Keystore is that encrypting sensitive data is only part of the problem.
We also need to ask:
Where does the encryption key come from?
And:
How is that key protected?
Encrypting local data while hardcoding the encryption key inside the application can give us a false sense of security.
Android Keystore gives us a platform-level mechanism designed specifically for managing cryptographic keys.
It doesn’t make an application magically secure.
But it allows us to avoid something we shouldn’t have been doing in the first place:
shipping raw cryptographic keys with the application.
Sometimes improving security isn’t about adding more encryption.
It’s about protecting the key that makes the encryption meaningful.