Sitemap
Mohammad Hasan Mahdavi

Mohammad Hasan Mahdavi

Press enter or click to view image in full size

Securely Storing JWT Tokens in Android with DataStore and Manual Encryption

9 min readAug 6, 2025

--

Introduction

When building secure Android apps, managing authentication tokens properly is critical. JWT (JSON Web Tokens) are widely used for user authentication and session management. Typically, you’ll have:

  • Access Token: Short-lived (e.g., 1 hour), used for API calls.
  • Refresh Token: Longer-lived, used to obtain new access tokens.

If an attacker gains access to these tokens, they can impersonate users or compromise your backend. That’s why secure storage of tokens on the device is essential.

Android offers options like EncryptedSharedPreferences and Credential Manager, but I wanted a solution that’s:

  • Modern (based on DataStore, the replacement for SharedPreferences)
  • Secure (uses Android Keystore for key management + AES encryption)
  • Lightweight (no unnecessary complexity)
  • Compatible with wider range of API levels (+23)

So, I built a custom token storage using Jetpack DataStore and manual AES encryption. Here’s how.

High-Level Overview

My implementation consists of two main components:

  1. CredentialManager → Handles storing and retrieving tokens securely using Jetpack DataStore.
  2. Encryptor → Provides AES/GCM encryption with keys managed by Android Keystore.

How it works together:

  • When you call saveAccessToken() or saveRefreshToken(), the token is encrypted using Encryptor.encrypt() and then stored in DataStore.
  • When you retrieve a token with getAccessToken() or getRefreshToken(), the encrypted value is fetched from DataStore and decrypted with Encryptor.decrypt().
  • Keys never leave the Android Keystore, making it secure against extraction from the device.
  • I added an in-memory cache in DataStoreEntity to avoid repeated decryptions, which improves performance.

My design follows separation of concerns:

  • CredentialManager → Focuses on persistence and caching.
  • Encryptor → Handles all cryptographic operations.

Next, I’ll break down CredentialManager in detail.

Deep Dive into CredentialManager

Important: The full code of CredentialManager class is attached at the end of this write-up.

CredentialManager is your main API for secure token storage. It manages:

  • DataStore integration for persistence.
  • Encryption/decryption via Encryptor.
  • In-memory caching for efficiency.

1. DataStore Setup

private val Context.dataStore by preferencesDataStore(name = "auth_credentials")
  • Uses Preferences DataStore with a file named auth_credentials.
  • This acts as a lightweight key-value storage (replacement for SharedPreferences).

2. Core Properties

private val encryptor: Encryptor by lazy { Encryptor() } 

private var refreshToken = DataStoreEntity(stringPreferencesKey(REFRESH_TOKEN))
private var accessToken = DataStoreEntity(stringPreferencesKey(ACCESS_TOKEN))
  • encryptor is created lazily to avoid unnecessary initialization.
  • refreshToken and accessToken each wrap a DataStore key inside DataStoreEntity.

3. Inner Class: DataStoreEntity

This is a generic wrapper for a single DataStore entry. It:

  • Caches the last retrieved value in memory.
  • Handles encryption/decryption transparently.
inner class DataStoreEntity(private val key: Preferences.Key<String>) {
private var value: String? = null

suspend fun get(): String? {
if (value == null) {
val prefs = context.dataStore.data.first()
val encrypted = prefs[key]
val decrypted = encrypted?.let {encryptor.decrypt(it)}
value = decrypted
}
return value
}

suspend fun set(input: String?) {
context.dataStore.edit { mutablePreferences ->
if (input == null) {
mutablePreferences.remove(key)
} else {
mutablePreferences[key] = encryptor.encrypt(input)
}
}
value = input
}
}

4. Public API of CredentialManager

  • saveRefreshToken() & saveAccessToken() → Encrypt and persist tokens.
  • getAccessToken() & getRefreshToken() → Return decrypted tokens.
  • clearTokens() → Deletes both tokens (calls set(null) internally).

5. Companion Object

companion object { 
private const val ACCESS_TOKEN = "access_token_key"
private const val REFRESH_TOKEN = "refresh_token_key"
}

Stores constant keys for DataStore entries.

Key Strengths of CredentialManager class:

  • Clean API for other layers (e.g., ViewModel can just call credentialManager.getAccessToken()).
  • Transparent security layer (encryption is automatic).
  • Performance-conscious (caching prevents repeated decryption).

Next, I’ll explain Encryptor in depth — its design, why AES/GCM is a strong choice, and how Android Keystore secures keys.

Deep Dive into Encryptor

Important: The full code of Encryptor class is attached at the end of this write-up.

Get Mohammad Hasan Mahdavi’s stories in your inbox

Join Medium for free to get updates from this writer.

Encryptor is the heart of your security layer. It ensures:

  • AES/GCM encryption for strong confidentiality and integrity.
  • Secure key management via Android Keystore (so keys never leave the device).

1. Why AES/GCM?

  • AES (Advanced Encryption Standard) is the industry standard for symmetric encryption.
  • GCM mode (Galois/Counter Mode) adds:
  • Authentication tag: Detects tampering.
  • No need for padding: efficient for any data size.
  • Choice of AES/GCM/NoPadding is excellent for modern Android apps.

2. Key Management with Keystore

private fun getOrCreateSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val existingKey = keyStore.getKey(KEY_ALIAS, null) as? SecretKey
if (existingKey != null) return existingKey
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
  • Checks if a key with alias MyAppCredentialKey exists; if not, generates a 256-bit AES key.
  • AndroidKeyStore ensures:
  • Keys are stored in secure hardware (TEE) on supported devices.
  • Keys cannot be extracted from the device (unlike SharedPreferences).

3. Encryption Process

fun encrypt(plainText: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv = cipher.iv
val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
val combined = ByteArray(iv.size + encryptedBytes.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size)
return Base64.encodeToString(combined, Base64.NO_WRAP)
}
  • Generates a random IV (Initialization Vector) for each encryption (important for GCM security).
  • Combines IV + ciphertext before Base64 encoding (so IV is available for decryption).
  • Uses NO_WRAP in Base64 to keep it clean for storage.

4. Decryption Process

fun decrypt(encryptedData: String): String {
val combined = Base64.decode(encryptedData, Base64.NO_WRAP)
val iv = combined.copyOfRange(0, IV_SIZE)
val cipherText = combined.copyOfRange(IV_SIZE, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(TAG_SIZE, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
val decryptedBytes = cipher.doFinal(cipherText)
return String(decryptedBytes, Charsets.UTF_8)
}
  • Extracts IV and ciphertext.
  • Initializes the cipher in decrypt mode with the same secret key and IV.
  • Returns the original plaintext string.

Security Strength:

  • AES-256 GCM is strong enough for modern security standards.
  • Random IV per encryption prevents pattern analysis.
  • Android Keystore makes it extremely hard for attackers to extract keys.

Putting It All Together (Usage + Security Considerations)

Now that we’ve broken down both classes, let’s see how they work together and why your approach is strong.

How It Works as a System

  1. App wants to save tokens → Calls credentialManager.saveAccessToken() or saveRefreshToken().
  2. CredentialManager passes the token to DataStoreEntity.set().
  3. set() → Encrypts token using Encryptor.encrypt().
  4. Encrypted string is stored in DataStore.
  5. To retrieve:
  • Call credentialManager.getAccessToken().
  • Reads from DataStore → Decrypts via Encryptor.decrypt() → Returns plain token.

Usage Example

val credentialManager = CredentialManager(context)

// Save tokens
credentialManager.saveAccessToken("JWT_ACCESS_TOKEN")
credentialManager.saveRefreshToken("JWT_REFRESH_TOKEN")

// Retrieve tokens
val accessToken = credentialManager.getAccessToken()
val refreshToken = credentialManager.getRefreshToken()

// Clear tokens on logout
credentialManager.clearTokens()

Security Considerations

Our implementation follows best practices:

✔ AES/GCM — Modern, secure encryption mode with integrity checks.
✔ Unique IV per encryption — Prevents reuse vulnerabilities.
✔ Keys in Android Keystore — Never stored in app storage, resistant to extraction.
✔ Base64 encoding — Makes encrypted data safe for storage in DataStore.
✔ No hardcoded keys — Key is generated and stored securely.

Additional recommendations:

  • Clear tokens from memory on logout → You already do this via clearTokens().
  • Handle failed decryption gracefully → Example: if data is corrupted or key changed after app reinstall.
  • Consider biometric authentication for extra security when retrieving tokens (optional future feature).

✅ This is a production-ready design for secure JWT storage in Android.

Full code of CredentialManager class:

package your.packagename.goes.here

import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first

/**
* DataStore instance for storing secure credentials.
*/
private val Context.dataStore by preferencesDataStore(name = "auth_credentials")

/**
* Manages secure storage and retrieval of user credentials (access and refresh tokens).
*
* This class uses Android Keystore to generate a secret key for encrypting and decrypting
* tokens, which are then stored in Jetpack DataStore.
*
* @property context The application context, used for accessing DataStore and KeyStore.
*/
class CredentialManager(private val context: Context) {

private val encryptor: Encryptor by lazy { Encryptor() }

private var refreshToken = DataStoreEntity(stringPreferencesKey(REFRESH_TOKEN))
private var accessToken = DataStoreEntity(stringPreferencesKey(ACCESS_TOKEN))

/**
* An inner class to handle a single entity in DataStore.
* It provides methods to get and set a string value associated with a given DataStore key,
* handling encryption and decryption.
*
* @property key The [Preferences.Key] for this DataStore entity.
*/
inner class DataStoreEntity(private val key: Preferences.Key<String>) {
private var value: String? = null

/**
* Retrieves the decrypted value from DataStore.
* If the value is not already cached in memory, it reads from DataStore,
* decrypts it, and then caches it.
*
* @return The decrypted string value, or null if not found or an error occurs.
*/
suspend fun get(): String? {
if (value == null) {
val prefs = context.dataStore.data.first()
val encrypted = prefs[key]
val decrypted = encrypted?.let { encryptor.decrypt(it) }
value = decryptedToken
}
return value
}

/**
* Encrypts and saves the given input string to DataStore.
* If the input is null, it removes the corresponding key from DataStore.
* The value is also updated in the in-memory cache.
*
* @param input The string value to encrypt and save, or null to remove the entry.
*/
suspend fun set(input: String?) {
context.dataStore.edit { mutablePreferences ->
if (input == null) {
mutablePreferences.remove(key)
} else {
mutablePreferences[key] = encryptor.encrypt(input)
}
}
value = input
}
}

/**
* Saves the refresh token to secure storage.
* @param token The refresh token to be saved.
*/
suspend fun saveRefreshToken(token: String) {
refreshToken.set(token)
}

/**
* Saves the access token to secure storage.
* @param accessToken The access token to be saved.
*/
suspend fun saveAccessToken(token: String) {
accessToken.set(token)
}

/**
* Retrieves the access token from secure storage.
* @return The access token, or null if not found.
*/
suspend fun getAccessToken(): String? {
return accessToken.get()
}

/**
* Retrieves the refresh token from secure storage.
* @return The refresh token, or null if not found.
*/
suspend fun getRefreshToken(): String? {
return refreshToken.get()
}

/**
* Clears both access and refresh tokens from secure storage.
*/
suspend fun clearTokens() {
accessToken.set(null)
refreshToken.set(null)
}

/**
* Companion object for [CredentialManager].
* Contains constants for DataStore keys.
*/
companion object {
/**
* DataStore key for the access token.
*/
private const val ACCESS_TOKEN = "access_token_key"
/**
* DataStore key for the refresh token.
*/
private const val REFRESH_TOKEN = "refresh_token_key"
}
}

Full code of Encryptor class:

package your.packagename.goes.here

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

/**
* Handles encryption and decryption of strings using AES/GCM.
*
* @property secretKey The [SecretKey] used for cryptographic operations.
*/
class Encryptor() {
private val secretKey: SecretKey by lazy { getOrCreateSecretKey() }

companion object {
// Alias for the secret key stored in Android Keystore.
private const val KEY_ALIAS = "MyAppCredentialKey"
// Transformation algorithm for AES encryption/decryption.
private const val TRANSFORMATION = "AES/GCM/NoPadding"
// Initialization Vector (IV) size in bytes for GCM mode.
private const val IV_SIZE = 12
// Authentication Tag size in bits for GCM mode.
private const val TAG_SIZE = 128
}

/**
* Retrieves an existing SecretKey from Android Keystore or creates a new one if not present.
* The key is used for encrypting and decrypting credentials.
*
* @return The [SecretKey] for cryptographic operations.
* @throws Exception if Keystore or cryptographic operations fail.
*/
private fun getOrCreateSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

val existingKey = keyStore.getKey(KEY_ALIAS, null) as? SecretKey
if (existingKey != null) return existingKey

val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256) // 256-bit AES key
.build()
keyGenerator.init(spec)
return keyGenerator.generateKey()
}

/**
* Encrypts the given plain text string using AES/GCM.
* The Initialization Vector (IV) is prepended to the ciphertext.
*
* @param plainText The string to encrypt.
* @return A Base64 encoded string of the IV combined with the encrypted data.
* @throws Exception if encryption fails.
*/
fun encrypt(plainText: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv = cipher.iv // Save the IV, GCM mode requires a unique IV for each encryption
val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))

// Prepend IV to the ciphertext for use during decryption
val combined = ByteArray(iv.size + encryptedBytes.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size)

return Base64.encodeToString(combined, Base64.NO_WRAP)
}

/**
* Decrypts the given Base64 encoded encrypted data using AES/GCM.
* It expects the IV to be prepended to the ciphertext.
*
* @param encryptedData A Base64 encoded string containing the IV and ciphertext.
* @return The original plain text string.
* @throws Exception if decryption fails (e.g., wrong key, corrupted data).
*/
fun decrypt(encryptedData: String): String {
val combined = Base64.decode(encryptedData, Base64.NO_WRAP)

// Extract IV from the beginning of the combined array
val iv = combined.copyOfRange(0, IV_SIZE)
val cipherText = combined.copyOfRange(IV_SIZE, combined.size)

val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(TAG_SIZE, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
val decryptedBytes = cipher.doFinal(cipherText)

return String(decryptedBytes, Charsets.UTF_8)
}
}

Acknowledgments

I want to extend a special thanks to ChatGPT for assisting me in organizing my thoughts, refining the implementation details, and helping craft this write-up. Using AI-powered tools like ChatGPT can significantly speed up the development process and help clarify complex concepts, making technical writing more accessible and enjoyable.

If you’re working on similar projects or exploring secure Android development, I highly recommend leveraging AI assistants — they can be a great partner for coding, debugging, and documentation.

--

--