Kotlin secure storage checklist for tokens, keys, and PII
Kotlin secure storage checklist to choose between Android Keystore, EncryptedSharedPreferences, and database encryption for tokens, keys, and PII.

What you're trying to protect (in plain terms)
Secure storage in a business app means one thing: if someone gets the phone (or your app's files), they still shouldn't be able to read or reuse what you saved. That includes data at rest (on disk) and also secrets leaking through backups, logs, crash reports, or debug tools.
A simple mental test: what could a stranger do if they opened your app's storage folder? In many apps, the most valuable items aren't photos or settings. They're small strings that unlock access.
On-device storage often includes session tokens (so users stay signed in), refresh tokens, API keys, encryption keys, personal data (PII) like names and emails, and cached business records used offline (orders, tickets, customer notes).
Here are common real-world failure modes:
- A lost or stolen device is examined, and tokens are copied to impersonate a user.
- Malware or a "helper" app reads local files on a rooted device or via accessibility tricks.
- Automatic device backups move your app data somewhere you didn't plan for.
- Debug builds log tokens, write them to crash reports, or disable security checks.
That's why "just store it in SharedPreferences" isn't OK for anything that grants access (tokens) or could harm users and your company (PII). Plain SharedPreferences is like writing secrets on a sticky note inside the app: convenient, and easy to read if someone gets a chance.
The most useful starting point is to name each stored item and ask two questions: does it unlock something, and would it be a problem if it became public? The rest (Keystore, encrypted preferences, encrypted database) follows from that.
Classify your data: tokens, keys, and PII
Secure storage gets easier when you stop treating all "sensitive data" the same. Start by listing what the app saves and what would happen if it leaked.
Tokens aren't the same as passwords. Access tokens and refresh tokens are meant to be stored so the user stays signed in, but they're still high-value secrets. Passwords shouldn't be stored at all. If you need login, store only what you must to keep a session (usually tokens) and rely on the server for password checks.
Keys are a different class. API keys, signing keys, and encryption keys can unlock entire systems, not just one user account. If someone extracts them from a device, they can automate abuse at scale. A good rule: if a value can be used outside the app to impersonate the app or decrypt data, treat it as higher risk than a user token.
PII is anything that can identify a person: email, phone, home address, customer notes, government IDs, health-related data. Even fields that look harmless become sensitive when combined.
A quick labeling system that works well in practice:
- Session secrets: access token, refresh token, session cookie
- App secrets: API keys, signing keys, encryption keys (avoid putting these on devices when possible)
- User data (PII): profile details, identifiers, documents, medical or financial info
- Device and analytics IDs: advertising ID, device ID, install ID (still sensitive under many policies)
Android Keystore: when to use it
Android Keystore is best when you need to protect secrets that should never leave the device in plain form. It's a safe for cryptographic keys, not a database for your actual data.
What it's good at: generating and holding keys used for encryption, decryption, signing, or verifying. You typically encrypt a token or offline data elsewhere, and a Keystore key is what unlocks it.
Hardware-backed keys: what that really means
On many devices, Keystore keys can be hardware-backed. That means key operations happen inside a protected environment and the key material can't be extracted. It lowers risk from malware that can read app files.
Hardware-backed isn't guaranteed on every device, and behavior varies by model and Android version. Build as if key operations can fail.
User authentication gates
Keystore can require user presence before a key can be used. That's how you tie access to biometrics or device credentials. For example, you can encrypt an export token and only decrypt it after the user confirms with fingerprint or PIN.
Keystore is a strong fit when you want a non-exportable key, when you want biometric or device-credential approval for sensitive actions, and when you want per-device secrets that shouldn't sync or travel with backups.
Plan for pitfalls: keys can be invalidated after lock screen changes, biometric changes, or security events. Expect failures and implement a clean fallback: detect invalid keys, wipe encrypted blobs, and prompt the user to sign in again.
EncryptedSharedPreferences: when it's enough
EncryptedSharedPreferences is a good default for a small set of secrets in key-value form. It's "SharedPreferences, but encrypted" so someone can't just open a file and read values.
Under the hood, it uses a master key to encrypt and decrypt values. That master key is protected by Android Keystore, so your app isn't storing the raw encryption key in plain text.
It's usually enough for a few small items you read often, like access and refresh tokens, session IDs, device IDs, environment flags, or small bits of state such as last sync time. It's also fine for tiny pieces of user data if you truly must store them, but it shouldn't become your dumping ground for PII.
It's not a good fit for anything large or structured. If you need offline lists, search, or querying by fields (customers, tickets, orders), EncryptedSharedPreferences becomes slow and awkward. That's the point where you want an encrypted database.
A simple rule: if you can list every stored key on one screen, EncryptedSharedPreferences is probably fine. If you need rows and queries, move on.
Database encryption: when you need it
Database encryption matters when you store more than a tiny setting or one token. If your app keeps business data on the device, assume it can be extracted from a lost phone unless you protect it.
A database makes sense when you need offline access to records, local caching for performance, history/audit trails, or long notes and attachments.
Two common encryption approaches
Full database encryption (often SQLCipher-style) encrypts the entire file at rest. Your app opens it with a key. This is easy to reason about because you don't have to remember which columns are protected.
App-layer field encryption encrypts only certain fields before writing, then decrypts after reading. This can work if most records aren't sensitive, or if you're trying to keep a specific database setup without changing the file format.
Tradeoffs: confidentiality vs search and sort
Full database encryption hides everything on disk, but once the database is unlocked, your app can query normally.
Field encryption protects specific columns, but you lose easy searching and sorting on encrypted values. Sorting by an encrypted last name doesn't work reliably, and searching becomes either "search after decrypt" (slow) or "store extra indexes" (more complexity and potential leaks).
Key management basics
The database key should never be hardcoded or shipped in the app. A common pattern is to generate a random database key, then store it wrapped (encrypted) using a key kept in Android Keystore. On logout, you can delete the wrapped key and treat the local database as disposable, or keep it if the app must work offline across sessions.
How to choose: a practical comparison
You're not picking "the most secure" option in general. You're picking the safest option that fits how your app uses the data.
Questions that actually drive the right choice:
- How often is the data read (every launch or rarely)?
- How much data is it (a few bytes or thousands of records)?
- What happens if it leaks (annoying, costly, legally reportable)?
- Do you need offline access, search, or sorting?
- Do you have compliance requirements (retention, audit, encryption rules)?
A workable mapping:
- Tokens (OAuth access and refresh tokens) usually belong in EncryptedSharedPreferences because they're small and read often.
- Key material should live in Android Keystore whenever possible to reduce the chance it can be copied off the device.
- PII and offline business data usually needs database encryption once you store more than a couple of fields or need offline lists and filtering.
Mixed data is normal in business apps. A practical pattern is to generate a random data encryption key (DEK) for your local database or file, store only the wrapped DEK using a Keystore-backed key, and rotate it when needed.
If you're unsure, choose the simpler safe path: store less. Avoid offline PII unless you truly need it, and keep keys in Keystore.
Step-by-step: implement secure storage in a Kotlin app
Start by writing down every value you plan to store on the device and the exact reason it must be there. This is the fastest way to prevent "just in case" storage.
Before you write code, decide your rules: how long each item should live, when it should be replaced, and what "logout" really means. An access token might expire in 15 minutes, a refresh token might last longer, and offline PII might need a firm "delete after 30 days" rule.
Implementation that stays maintainable:
- Create a single "SecureStorage" wrapper so the rest of the app never touches SharedPreferences, Keystore, or the database directly.
- Put each item in the right place: tokens in EncryptedSharedPreferences, encryption keys protected by Android Keystore, and larger offline datasets in an encrypted database.
- Handle failures on purpose. If secure storage fails, fail closed. Don't silently fall back to plain storage.
- Add diagnostics without leaking data: log event types and error codes, never tokens, keys, or user details.
- Wire deletion paths: logout, account removal, and "clear app data" should funnel into the same wipe routine.
Then test the boring cases that break secure storage in production: restoring from a backup, upgrading from an older app version, changing device lock settings, migrating to a new phone. Make sure users don't get stuck in a loop where stored data can't be decrypted but the app keeps retrying.
Finally, write down the decisions in one page the whole team can follow: what is stored, where, retention periods, and what should happen when decryption fails.
Common mistakes that break secure storage
Most failures aren't about choosing the wrong library. They happen when one small shortcut quietly copies secrets into places you didn't mean to store them.
The biggest red flag is a refresh token (or long-lived session token) saved in plaintext anywhere: SharedPreferences, a file, a "temporary" cache, or a local database column. If someone gets a backup, a rooted device dump, or a debug build artifact, that token can outlive the password.
Secrets also leak through visibility, not storage. Logging full request headers, printing tokens during debugging, or attaching "helpful" context to crash reports and analytics events can expose credentials outside the device. Treat logs as public.
Key handling is another common gap. Using one key for everything increases the blast radius. Never rotating keys means old compromises stay valid. Include a plan for key versioning, rotation, and what happens to old encrypted data.
Don't forget the "outside the vault" paths
Encryption doesn't stop cloud backups from copying local app data. It doesn't stop screenshots or screen recording capturing PII. It doesn't stop debug builds with relaxed settings, or export features (CSV/share sheets) leaking sensitive fields. Clipboard usage can also leak one-time codes or account numbers.
Also, encryption doesn't fix authorization. If your app shows PII after a user logs out, or keeps cached data accessible without re-auth, that's an access control bug. Lock the UI, wipe sensitive caches on logout, and re-check permissions before displaying protected data.
Operational details: lifecycle, logout, and edge cases
Secure storage isn't only where you put secrets. It's how they behave over time: when the app sleeps, when a user logs out, and when the device is locked.
For tokens, plan the full lifecycle. Access tokens should be short-lived. Refresh tokens should be treated like passwords. If a token is expired, refresh it quietly. If a refresh fails (revoked, password changed, device removed), stop retry loops and force a clean sign-in. Also support server-side revocation. Perfect local storage can't help if you never invalidate stolen credentials.
Use biometrics for re-auth, not for everything. Prompt when the action has real risk (viewing PII, exporting data, changing payout details, showing a one-time key). Don't prompt on every app open.
On logout, be strict and predictable:
- Clear in-memory copies first (tokens cached in singletons, interceptors, or ViewModels).
- Wipe stored tokens and session state (including refresh tokens).
- Remove or invalidate local encryption keys if your design supports it.
- Delete offline PII and cached API responses.
- Disable background jobs that might re-fetch data.
Edge cases matter in business apps: multiple accounts on one device, work profiles, backup/restore, device-to-device transfer, and partial logouts (switch company/workspace rather than full sign-out). Test force stop, OS upgrades, and clock changes since time drift can break expiry logic.
Tampering detection is a tradeoff. Basic checks (debuggable builds, emulator flags, simple root signals, Play Integrity verdicts) can reduce casual abuse, but determined attackers can bypass them. Treat tamper signals as risk inputs: limit offline access, require re-auth, and log the event.
Quick checklist before you ship
Use this before release. It targets the places where secure storage fails in real business apps.
- Assume the device can be hostile. If an attacker has a rooted device or full device image, can they read tokens, keys, or PII from app files, preferences, logs, or screenshots? If the answer is "maybe," move secrets to Keystore-backed protection and keep the payload encrypted.
- Check backups and device transfers. Keep sensitive files out of Android Auto Backup, cloud backups, and device-to-device transfer. If losing a key on restore would break decryption, plan the recovery flow (re-auth and re-download instead of trying to decrypt).
- Hunt for accidental plaintext on disk. Look for temp files, HTTP caches, crash reports, analytics events, and image caches that might contain PII or tokens. Check debug logging and JSON dumps.
- Expire and rotate. Access tokens should be short-lived, refresh tokens protected, and server-side sessions revocable. Define key rotation and what the app does when a token is rejected (clear, re-auth, retry once).
- Reinstall and device-change behavior. Test uninstall and reinstall, then open offline. If Keystore keys are gone, the app should fail safely (wipe encrypted data, show sign-in, avoid partial reads that corrupt state).
A fast validation is a "bad day" test: a user logs out, changes their password, restores a backup to a new phone, and opens the app on a plane. The result should be predictable: either data decrypts for the right user, or it's wiped and re-fetched after sign-in.
Example scenario: a business app that stores PII offline
Imagine a field sales app used in areas with poor signal. Reps log in once in the morning, browse assigned customers offline, add meeting notes, then sync later. This is where a storage checklist stops being theory and starts preventing real leaks.
A practical split:
- Access token: keep short-lived and store in EncryptedSharedPreferences.
- Refresh token: protect more tightly and gate access through Android Keystore.
- Customer PII (names, phones, addresses): store in an encrypted local database.
- Offline notes and attachments: store in the encrypted database, with extra care for exports and sharing.
Now add two features and the risk changes.
If you add "remember me," the refresh token becomes the main door back into the account. Treat it like a password. Depending on your users, you might require device unlock (PIN/pattern/biometric) before decrypting it.
If you add offline mode, you're no longer protecting only a session. You're protecting a full customer list that can be valuable on its own. That usually pushes you toward database encryption plus clear logout rules: wipe local PII, keep only what's needed for the next login, and cancel background sync.
Test on real devices, not only emulators. At minimum, verify lock/unlock behavior, reinstall behavior, backup/restore, and multi-user or work profile separation.
Next steps: make it a repeatable team habit
Secure storage only works when it's a habit. Write a short storage policy your team can follow: what goes where (Keystore, EncryptedSharedPreferences, encrypted database), what is never stored, and what must be wiped on logout.
Make it part of everyday delivery: definition of done, code review, and release checks.
A lightweight reviewer checklist:
- Every stored item is labeled (token, key material, or PII).
- The storage choice is justified in code comments.
- Logout and account switch remove the right data (and only that data).
- Errors and logs never print secrets or full PII.
- Someone owns the policy and keeps it current.
If your team uses AppMaster (appmaster.io) to build business apps and exports Kotlin source for the Android client, keep the same SecureStorage wrapper approach so generated and custom code follow one consistent policy.
Start with a tiny proof-of-concept
Build a small POC that stores one auth token and one PII record (for example, a customer phone number needed offline). Then test fresh install, upgrade, logout, lock screen changes, and clear app data. Expand only after wipe behavior is correct and repeatable.
FAQ
Start by listing exactly what you store and why. Put small session secrets like access and refresh tokens in EncryptedSharedPreferences, keep cryptographic keys in Android Keystore, and use an encrypted database for offline business records and PII once you have more than a couple of fields or need queries.
Plain SharedPreferences stores values in a file that can often be read from device backups, rooted device file access, or debugging artifacts. If the value is a token or any PII, treating it like a normal setting makes it much easier to copy and reuse outside the app.
Use Android Keystore to generate and hold cryptographic keys that should not be extractable. You typically use those keys to encrypt other data (tokens, database keys, files), and optionally require user authentication (biometric or device credential) before the key can be used.
It means key operations can happen in protected hardware so the key material is harder to extract, even if an attacker can read app files. Don’t assume it’s always available or always behaves the same; design for failures and have a recovery flow when keys are unavailable or invalidated.
It’s usually enough for a small set of frequently read key-value secrets like access/refresh tokens, session IDs, and small pieces of state. It’s not a good fit for large data, structured offline records, or anything you need to query and filter like customers, tickets, or orders.
Choose an encrypted database when you store offline business data or PII at scale, need querying/searching/sorting, or keep history for offline use. It reduces the risk of a lost device exposing entire customer lists or notes, while still letting the app work offline with a clear key strategy.
Full database encryption protects the whole file at rest and is easier to reason about because you don’t have to track which columns are sensitive. Field encryption can work for a few columns but makes search and sort hard, and it’s easy to accidentally leak data through indexes or derived fields.
Generate a random database key, then store it only in wrapped form (encrypted) using a Keystore-backed key. Never hardcode keys or ship them in the app, and decide what happens on logout or key invalidation (often: delete the wrapped key and treat local data as disposable).
Keys can be invalidated by lock screen or biometric changes, OS security events, or restore/migration scenarios. Handle it explicitly: detect decryption failures, wipe the encrypted blobs or local database safely, and prompt the user to sign in again rather than looping or falling back to plaintext storage.
Most leaks happen “outside the vault”: logs, crash reports, analytics events, debug prints, HTTP caches, screenshots, clipboard use, and backup/restore paths. Treat logs as public, never record tokens or full PII, disable accidental export paths, and make logout wipe both stored data and in-memory copies.


