This tutorial covers only: 1. Secure tokens 2. Encrypted data 3. Room database security 4. Avoiding password, OTP and MPIN storage
Secure Local Storage in Android
Secure local storage protects sensitive data saved on an Android device.
1. Secure Storage Architecture
flowchart TD
A["Application receives data"] --> B{"Is data sensitive?"}
B -- No --> C["Store using Room or DataStore"]
B -- Yes --> D{"Must it be stored?"}
D -- No --> E["Keep temporarily in memory"]
D -- Yes --> F["Encrypt sensitive data"]
F --> G["Store encrypted value"]
H["Key generated in Android Keystore"] --> FThe most important rule is:
> The safest sensitive data is data that the application does not store.
---
2. Understanding Sensitive Data
Sensitive data includes:
* Access tokens
* Refresh tokens
* Passwords
* OTPs
* MPINs
* Card details
* Bank account information
* Personal information
* Health information
* Private documents
* Encryption keys
The required protection depends on:
* Data sensitivity
* How long it is required
* Whether offline access is required
* Effect of device compromise
* Business and regulatory requirements
---
3. Secure Tokens
What is a token?
A token represents an authenticated session. After successful authentication, the server normally returns:
* Access token
* Refresh token
* Token expiry information
sequenceDiagram
participant U as User
participant A as Android App
participant S as Server
U->>A: Enter credentials
A->>S: Authenticate through HTTPS
S-->>A: Access token and refresh token
A->>A: Protect tokens locally
A->>S: API request with access token
S-->>A: Protected responseAccess token
An access token:
* Is sent with protected API requests.
* Should normally have a short lifetime.
* Should be held in memory when possible.
* May require encrypted persistence if session restoration is required.
Refresh token
A refresh token:
* Is used to obtain a new access token.
* Usually has a longer lifetime.
* Requires stronger protection.
* Should support server-side revocation.
* Should preferably be rotated after use.
* Must be cleared during logout.
Recommended token-storage strategy
| Token | Recommended storage |
|---|---|
| Active access token | Memory when practical |
| Persisted access token | Encrypted local storage |
| Refresh token | Encrypted local storage with stronger controls |
| Token encryption key | Android Keystore |
| Password | Do not store |
| OTP | Do not persist |
| MPIN | Do not store as plain text or reversible local value |
Token storage flow
flowchart TD
A["Server returns tokens"] --> B["Access token used by API client"]
B --> C["Keep active token in memory"]
A --> D{"Persistence required?"}
D -- No --> E["Do not write tokens to disk"]
D -- Yes --> F["Encrypt token"]
G["Android Keystore key"] --> F
F --> H["Store ciphertext in private storage"]Token-storage implementation design
Create a TokenStore abstraction:
interface TokenStore {
suspend fun saveTokens(tokens: AuthTokens)
suspend fun getAccessToken(): String?
suspend fun getRefreshToken(): String?
suspend fun clearTokens()
}data class AuthTokens(
val accessToken: String,
val refreshToken: String,
val expiresAt: Long
)A secure implementation should:
1. Generate or load a key from Android Keystore.
2. Encrypt the token using authenticated encryption.
3. Store only ciphertext, IV and necessary metadata.
4. Decrypt only when the token is required.
5. Clear all tokens when the session ends.
Never store tokens in
* Source code
* BuildConfig
* XML resources
* Plain SharedPreferences
* Plain DataStore
* Plain Room columns
* Logs
* Crash reports
* Analytics events
* Clipboard
* URL query parameters
Important clarification
DataStore is a modern replacement for SharedPreferences, but its normal use does not automatically mean that sensitive values are encrypted. Android recommends DataStore for small datasets and Room for larger relational data. Sensitive values still need an appropriate encryption design. Android DataStore guidance
Also, EncryptedSharedPreferences and related androidx.security.crypto APIs are currently deprecated. For new security-sensitive designs, use standard cryptographic APIs and protect keys with Android Keystore. AndroidX Security Crypto API
---
4. Android Keystore
What is Android Keystore?
Android Keystore is a system-provided container for cryptographic keys.
A key generated inside Keystore can remain non-exportable, meaning the application can request cryptographic operations without directly reading the raw key material.
It can also restrict:
* Which operations may use the key
* Which cryptographic modes are allowed
* Whether user authentication is required
* Whether biometric authentication is required
Android Keystore documentation
Correct design
Wrong:
Encryption key → Hard-coded in APK
Sensitive data → Encrypted using hard-coded keyRecommended:
Encryption key → Android Keystore
Sensitive data → Encrypted using Keystore-protected key
Ciphertext → DataStore, Room or private fileGenerating an AES key
private const val KEY_ALIAS = "local_data_key"
fun generateKey() {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val specification = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or
KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(
KeyProperties.ENCRYPTION_PADDING_NONE
)
.build()
keyGenerator.init(specification)
keyGenerator.generateKey()
}Retrieving the key
fun getSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
return keyStore.getKey(
KEY_ALIAS,
null
) as SecretKey
}Why use AES-GCM?
AES-GCM provides:
* Confidentiality: Data cannot be read without the key.
* Integrity: Modified ciphertext can be detected.
* Authentication: Encryption output includes an authentication tag.
---
5. Encrypting Sensitive Data
Encryption flow
flowchart LR
A["Plain data"] --> B["AES-GCM encryption"]
K["Keystore key"] --> B
B --> C["Ciphertext"]
B --> D["Unique IV"]
C --> E["Private local storage"]
D --> EEncryption example
data class EncryptedValue(
val cipherText: ByteArray,
val initializationVector: ByteArray
)
fun encrypt(
plainText: ByteArray,
secretKey: SecretKey
): EncryptedValue {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val encryptedBytes = cipher.doFinal(plainText)
return EncryptedValue(
cipherText = encryptedBytes,
initializationVector = cipher.iv
)
}Decryption example
fun decrypt(
encryptedValue: EncryptedValue,
secretKey: SecretKey
): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val parameterSpec = GCMParameterSpec(
128,
encryptedValue.initializationVector
)
cipher.init(
Cipher.DECRYPT_MODE,
secretKey,
parameterSpec
)
return cipher.doFinal(encryptedValue.cipherText)
}Important encryption rules
* Generate the key using Android Keystore.
* Use modern authenticated encryption such as AES-GCM.
* Use a unique IV for every encryption operation.
* Store the IV with the ciphertext.
* Never reuse an IV with the same AES-GCM key.
* Never hard-code the encryption key.
* Never create a custom cryptographic algorithm.
* Never treat Base64 as encryption.
Is the IV secret?
No. The IV does not generally need to be secret.
It can be stored with the ciphertext:
Stored value = IV + CiphertextHowever, it must be unique for each encryption with the same key.
Base64 usage
Binary ciphertext and IV may be converted to Base64 for storage:
val encodedCipherText = Base64.encodeToString(
encryptedValue.cipherText,
Base64.NO_WRAP
)Base64 is only an encoding format. It does not secure plain data.
---
6. Room Database Security
What is Room?
Room is an abstraction layer over SQLite used for storing structured data locally.
Common uses include:
* Cached API responses
* User-generated records
* Offline data
* Transaction history
* Application configuration
Does Room encrypt data automatically?
No.
A normal Room database is stored as a regular SQLite database inside the application’s private storage.
The application sandbox protects it from ordinary apps, but the database may still be exposed through:
* Compromised or rooted devices
* Insecure backups
* Debug builds
* Accidental file sharing
* Application vulnerabilities
* Physical extraction in some threat models
Room security levels
Level 1: Store only non-sensitive data
Best option when possible:
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val summary: String
)No additional encryption is required if the data is public and the threat model does not require it.
Level 2: Encrypt selected fields
Encrypt only sensitive columns:
@Entity(tableName = "customer")
data class CustomerEntity(
@PrimaryKey val customerId: String,
val displayName: String,
val encryptedAccountNumber: String,
val accountNumberIv: String
)flowchart LR
A["Sensitive field"] --> B["Encrypt"]
B --> C["Room stores ciphertext"]
C --> D["Read ciphertext"]
D --> E["Decrypt when required"]Advantages:
* Only sensitive fields are encrypted.
* Non-sensitive columns remain searchable.
* Smaller performance impact.
Limitations:
* More application code is required.
* Encrypted fields cannot be searched or sorted normally.
* Developers must manage IVs and key lifecycle correctly.
Level 3: Encrypt the complete database
For highly sensitive applications, the entire SQLite database can be encrypted using a carefully reviewed database-encryption solution.Considerations include:
* Encryption-library maintenance
* Database performance
* Key storage
* Migration strategy
* Backup handling
* Key rotation
* Recovery after Keystore invalidation
* Compatibility with Room versions
Whole-database encryption is not built into Room itself.
Field encryption with a converter
A simplified conceptual converter:
class SecureStringConverter(
private val cryptoManager: CryptoManager
) {
@TypeConverter
fun encrypt(value: SensitiveString?): String? {
return value?.let(cryptoManager::encrypt)
}
@TypeConverter
fun decrypt(value: String?): SensitiveString? {
return value?.let(cryptoManager::decrypt)
}
}Be careful with global converters because accidentally applying encryption to IDs or searchable fields can break queries. Explicit conversion in the repository or data-mapping layer is often easier to understand and audit.
Database backup protection
Sensitive Room data should not be copied into uncontrolled backups.
Possible manifest configuration:
<application
android:allowBackup="false"
android:fullBackupContent="false">
</application>If the application requires backups, use backup rules to exclude sensitive database, token and key-related files rather than automatically disabling every backup.Room logging
Avoid enabling database query logging for sensitive production data.
Never log:
Log.d("Database", "Account number: $accountNumber")Also review:
* Debug database inspectors
* Analytics
* Crash reports
* SQL error messages
* Test fixtures
* Screenshots
Room data deletion
On logout, delete user-specific data when required:
@Dao
interface AccountDao {
@Query("DELETE FROM accounts")
suspend fun deleteAllAccounts()
}A logout operation may need to clear:
* Tokens
* User profile
* Cached accounts
* Transaction information
* Pending sensitive operations
* User-specific files
* Sensitive notifications
Do not delete data that must legally or functionally remain without confirming the business requirements.
---
7. Avoid Password Storage
Why should passwords not be stored?
A password is a reusable authentication secret. If the application stores it, an attacker may use it:
* On the same application
* On the application’s website
* On other services where the user reused it
Correct password flow
flowchart TD
A["User enters password"] --> B["Send through HTTPS"]
B --> C["Server validates password"]
C --> D["Server returns tokens"]
D --> E["Remove password from app state"]
E --> F["Use tokens for later requests"]Never store passwords in
* SharedPreferences
* DataStore
* Room
* Files
* Logs
* ViewModel after authentication
* SavedStateHandle
* savedInstanceState
* Clipboard
* Analytics
* Crash reports
“Remember me” implementation
“Remember me” should not mean saving the password.
It should mean:
User authenticates
↓
Server issues revocable token
↓
App securely stores protected token
↓
App restores session using tokenThe server must be able to expire and revoke the session.
---
8. Avoid OTP Storage
What is an OTP?
OTP means One-Time Password. It should be:
* Short-lived
* Single-use
* Validated by the server
* Removed after validation or expiry
Correct OTP flow
sequenceDiagram
participant U as User
participant A as Android App
participant S as Server
S-->>U: Send OTP
U->>A: Enter OTP
A->>S: Submit OTP through HTTPS
S->>S: Validate OTP and expiry
S-->>A: Success or failure
A->>A: Clear OTP immediatelyDo not store OTP in
* Room
* DataStore
* SharedPreferences
* SavedStateHandle
* Logs
* Analytics
* Clipboard
* Notification history
* Files
Temporary OTP handling
Keep it only as temporary UI state:
var otp by mutableStateOf("")
private set
fun submitOtp() {
viewModelScope.launch {
try {
repository.verifyOtp(otp)
} finally {
otp = ""
}
}
}Also clear the OTP when:
* Validation succeeds
* Validation fails according to policy
* OTP expires
* User leaves the flow
* ViewModel is cleared
The server, not the mobile app, should decide whether the OTP is valid, expired or already used.
---
9. Avoid MPIN Storage
What is an MPIN?
An MPIN is a short numeric secret used to authenticate the user or authorize sensitive operations.
Because it has a small number of possible combinations, storing it locally is especially dangerous.
For example, a four-digit MPIN has only:0000 to 9999 = 10,000 possibilitiesIf attackers obtain a locally stored MPIN hash, they may attempt every possible value quickly unless the security design includes strong server-side controls.Recommended MPIN flow
flowchart TD
A["User enters MPIN"] --> B["Send through secure channel"]
B --> C["Server validates MPIN"]
C --> D{"Valid?"}
D -- Yes --> E["Issue or continue secure session"]
D -- No --> F["Apply retry and lockout policy"]
E --> G["Clear MPIN from memory"]
F --> GNever store
* Plain MPIN
* Encrypted MPIN for later recovery
* MPIN in logs
* MPIN in database
* MPIN in DataStore
* MPIN in SavedStateHandle
* MPIN inside the APK
Encryption is reversible. If the application can decrypt a stored MPIN automatically, an attacker who compromises the application environment may attempt to do the same.
Local biometric login
Instead of storing an MPIN, use:
* BiometricPrompt
* Device credentials
* A Keystore key requiring user authentication
* A server-issued, revocable session token
Biometric authentication
↓
Unlock Keystore-protected key
↓
Decrypt protected session token
↓
Server validates sessionBiometrics or device credentials protect access to the key. The server still performs final authorization.
---
10. Storage Decision Table
| Data | Store locally? | Recommended approach |
|---|---|---|
| Password | No | Send securely and discard |
| OTP | No | Keep temporarily in memory |
| MPIN | No | Validate securely; discard immediately |
| Access token | Only if required | Memory or encrypted local storage |
| Refresh token | If session restoration requires it | Encrypt with Keystore-protected key |
| Encryption key | Yes | Android Keystore |
| Public API cache | Yes | Room |
| Sensitive API cache | Only if required | Encrypted fields or encrypted database |
| User settings | Yes | DataStore |
| Sensitive user setting | Only if required | Encrypt before persistence |
| Card CVV | No | Never persist |
| Account details | Only if required | Minimize and encrypt sensitive fields |
---
11. Complete Secure Storage Flow
flowchart TD
A["Data received"] --> B{"Password, OTP, MPIN or CVV?"}
B -- Yes --> C["Use temporarily and discard"]
B -- No --> D{"Does it need persistence?"}
D -- No --> E["Keep only in memory"]
D -- Yes --> F{"Is it sensitive?"}
F -- No --> G["Room, DataStore or private file"]
F -- Yes --> H["Encrypt using authenticated encryption"]
I["Key protected by Android Keystore"] --> H
H --> J["Store ciphertext in private storage"]---
12. Common Mistakes
1. Treating private storage as encryption
Internal storage is sandboxed, but it does not automatically encrypt application-level values.
2. Storing a token in plain DataStore
DataStore improves data-storage APIs but plain DataStore does not automatically provide the complete encryption design needed for sensitive tokens.
3. Hard-coding the encryption key
const val KEY = "my-secret-key" // InsecureAPK files can be reverse-engineered.
4. Storing the encryption key beside the encrypted value
Encrypted token → preferences
Encryption key → same preferencesAn attacker obtaining both may decrypt the token.
5. Assuming Room is encrypted
Room provides structured persistence over SQLite. It does not automatically encrypt database contents.
6. Reusing an AES-GCM IV
An IV must be unique for each encryption operation performed with the same key.
7. Logging authentication data
Tokens, passwords, OTPs and MPINs must not appear in logs, analytics or crash reports.
8. Storing a password for “Remember me”
Store an appropriately protected, revocable session token—not the password.
9. Hashing a short MPIN locally
A four- or six-digit MPIN has limited possibilities. A local hash alone does not provide safe authentication.
10. Forgetting logout cleanup
Logout should clear protected tokens, sensitive caches and user-specific background operations according to business requirements.
---
13. Interview Questions
1. What is secure local storage?
It is a design that minimizes sensitive data stored on the device and protects necessary persisted data using private storage, encryption and securely managed keys.
2. Where should encryption keys be stored?
They should generally be generated and protected using Android Keystore.
3. Does Android Keystore store normal application data?
No. It primarily protects cryptographic keys. The encrypted application data is normally stored in Room, DataStore or private files.
4. Does DataStore encrypt data automatically?
Normal DataStore usage should not be assumed to encrypt sensitive values. Encrypt sensitive information using an appropriate, reviewed design.
5. Does Room encrypt its database?
No. Room is an abstraction over SQLite and does not provide automatic database encryption.
6. How can sensitive Room data be protected?
Minimize stored data, encrypt selected fields or use a reviewed whole-database encryption solution, while protecting keys through Android Keystore.
7. Should access tokens be stored?
Keep access tokens in memory when practical. If persistence is required, encrypt them and protect the encryption key using Android Keystore.
8. Why does a refresh token require stronger protection?
It is longer-lived and can be used to obtain new access tokens.
9. Should an Android application store a password?
No. It should submit the password securely for authentication, receive tokens and discard the password.
10. Should OTP be stored in SavedStateHandle?
No. SavedStateHandle may persist its content for state restoration. OTP should remain temporary and be cleared quickly.
11. Can an MPIN be stored after encryption?
Usually, no. Authentication secrets such as MPINs should not be locally recoverable. Prefer server validation or secure platform authentication.
12. Is Base64 an encryption mechanism?
No. Base64 is reversible encoding and provides no security.
13. Why is AES-GCM commonly used?
It provides both confidentiality and integrity protection.
14. What happens to encrypted data if a Keystore key becomes unavailable?
The application may no longer be able to decrypt that data. The design needs a recovery strategy, such as clearing the local session and requiring authentication again.
15. What should happen on logout?
Revoke the session where supported, clear tokens and remove sensitive user-specific cached data according to application requirements.
---
Quick Revision
Secure tokens
→ Keep in memory when possible
→ Encrypt if persistence is required
→ Protect keys with Android Keystore
Encrypted data
→ Use authenticated encryption such as AES-GCM
→ Use a unique IV
→ Never hard-code keys
Room security
→ Room is not encrypted automatically
→ Minimize data
→ Encrypt sensitive fields or use reviewed DB encryption
Password
→ Never store
OTP
→ Keep temporarily in memory and discard
MPIN
→ Do not store as plain text or a recoverable local value