移动端登录态安全设计(4):DataStore、MMKV、SharedPreferences:Token 到底应该存哪里?
前面几篇我们已经讲了三个问题第1篇App 登录时密码到底要不要加密为什么通常走 HTTPS 第2篇Token 拿到后为什么不能明文存 第3篇AES-GCM Android KeystoreAndroid Token 本地安全存储到了实际项目里很多 Android 开发都会继续遇到一个问题Token 到底应该存 DataStore、MMKV还是 SharedPreferences这个问题看起来是在选技术方案实际上更应该先问另一个问题Token 是明文存还是加密后再存因为从安全角度看DataStore、MMKV、SharedPreferences 本质上都只是本地存储容器。真正决定 Token 安全性的不是“存在哪里”而是Token 有没有明文落地 AES key 有没有被安全保护 日志有没有打印 Token 退出登录时有没有清 Token 刷新失败时有没有清登录态所以这一篇的核心结论是DataStore、MMKV、SharedPreferences 只是柜子AES-GCM 才是锁Android Keystore 才是保管钥匙的地方。一、先说结论存哪里不是第一优先级很多人会觉得SharedPreferences 不安全换成 DataStore 就安全了。 MMKV 性能高所以 Token 存 MMKV 就更好。 DataStore 是官方推荐所以 Token 存 DataStore 就没问题。这些理解都不够准确。如果你这样写dataStore.edit { it[ACCESS_TOKEN] accessToken }或者mmkv.encode(accessToken, accessToken)再或者sharedPreferences.edit() .putString(accessToken, accessToken) .apply()本质上都是Token 明文存储区别只是明文 Token 存在 DataStore 里 明文 Token 存在 MMKV 里 明文 Token 存在 SharedPreferences 里从安全角度看这都不是理想方案。正确方向应该是Token 明文 ↓ AES-GCM 加密 ↓ Token 密文 ↓ DataStore / MMKV / SharedPreferences 保存密文 AES key ↓ Android Keystore 生成和保护也就是先加密再存储。二、DataStore / MMKV / SharedPreferences 都只是存储容器这三个东西的定位不一样但它们都不是 Token 安全方案本身。它们解决的是数据怎么保存到本地 数据怎么从本地读取出来而不是直接解决Token 如何加密 AES key 如何保护 Token 泄漏后如何止损 日志如何脱敏 登录态如何失效所以不要把“存储容器”和“安全方案”混在一起。更准确的分工应该是Android Keystore 负责保护 AES key AES-GCM 负责加密和解密 Token DataStore / MMKV / SharedPreferences 负责保存加密后的密文也就是说Keystore 管钥匙 AES-GCM 负责上锁 DataStore / MMKV / SharedPreferences 只是柜子三、Token 存储的正确流程登录成功后后端返回{ accessToken: xxx, refreshToken: yyy }移动端不要直接保存accessToken xxx refreshToken yyy而应该这样处理1. 把 accessToken / refreshToken 封装成 TokenEntity 2. 把 TokenEntity 序列化成 JSON 3. 使用 AES-GCM 加密 JSON 4. 得到 iv cipherText 5. 把 iv cipherText 存到本地 6. AES key 由 Android Keystore 生成和保护读取时反过来1. 从本地读取 iv cipherText 2. 从 Android Keystore 获取 AES key 3. 使用 AES-GCM 解密 4. 得到 Token JSON 5. 反序列化成 TokenEntity 6. 运行时拿 accessToken 加到 Authorization Header所以本地文件里看到的应该是{ token_iv: Base64后的IV, token_cipher_text: Base64后的密文 }而不是{ accessToken: eyJhbGciOiJIUzI1NiJ9.xxxxxx, refreshToken: refresh_xxxxxx }四、SharedPreferences老项目常见但新项目不建议作为主力SharedPreferences 是 Android 很早就有的 key-value 存储方案。很多老项目都会这样存 Tokenval sp context.getSharedPreferences(auth, Context.MODE_PRIVATE) sp.edit() .putString(accessToken, accessToken) .putString(refreshToken, refreshToken) .apply()它的优点是简单 API 熟悉 接入成本低 老项目里大量存在但缺点也明显方案比较老 容易被业务代码到处直接调用 容易变成全局配置垃圾桶 读写模型不如 DataStore 更适合协程和 Flow 明文存 Token 风险很高所以我的建议是老项目已经用了 SharedPreferences 不要一刀切先封装再加密再逐步迁移。 新项目 不建议继续把 SharedPreferences 作为主力 Token 存储方案。但注意SharedPreferences 本身不是重点。如果短期还必须用 SharedPreferences至少要做到Token 先 AES-GCM 加密再把密文存进去。五、MMKV性能好老项目可以继续用但仍然要存密文MMKV 在很多 Android 项目里很常见。常见写法val mmkv MMKV.defaultMMKV() mmkv.encode(accessToken, accessToken) mmkv.encode(refreshToken, refreshToken)MMKV 的优势是性能好 API 简单 接入方便 很多项目已经稳定使用 适合高频 key-value 读写如果老项目已经大量使用 MMKV没有必要为了“显得新”就强行替换成 DataStore。工程里最忌讳的是为了替换而替换但是MMKV 也不是 Token 安全方案本身。如果你明文存mmkv.encode(accessToken, accessToken)那仍然是明文存储。正确做法应该是Token 明文 ↓ AES-GCM 加密 ↓ Token 密文 ↓ MMKV 保存密文所以结论是已有项目可以继续使用 MMKV。 但 Token 必须先加密再存 MMKV。六、DataStore新项目更推荐适合协程、Flow、ComposeDataStore 是 Jetpack 提供的数据存储方案。它相比 SharedPreferences更适合现在的 Android 开发体系Kotlin 协程 Flow Compose MVVM / MVI 模块化 KMP 方向DataStore 的特点是基于协程和 Flow 异步读取 事务性更新 适合小型本地数据存储 支持 key-value 和 typed objectDataStore 常见有两种Preferences DataStore 类似 key-value上手简单适合存少量配置和密文。 Proto DataStore 强类型需要 proto schema更适合结构化数据。如果只是保存 Token 密文Preferences DataStore 就够用了。比如保存token_iv token_cipher_text或者access_token_iv access_token_cipher_text refresh_token_iv refresh_token_cipher_text但是要注意DataStore 也不负责加密。错误示例private val ACCESS_TOKEN stringPreferencesKey(access_token) suspend fun saveAccessToken(accessToken: String) { context.dataStore.edit { preferences - preferences[ACCESS_TOKEN] accessToken } }这仍然是 Token 明文存储。正确方向是suspend fun saveToken(token: TokenEntity) { val tokenJson json.encodeToString(token) val encryptedValue cryptoManager.encrypt(tokenJson) context.dataStore.edit { preferences - preferences[TOKEN_IV] encryptedValue.iv preferences[TOKEN_CIPHER_TEXT] encryptedValue.cipherText } }也就是TokenEntity ↓ JSON ↓ AES-GCM 加密 ↓ iv cipherText ↓ DataStore 保存七、三种方案怎么选可以用一张表看清楚。方案优点缺点Token 存储建议SharedPreferences简单老项目常见方案偏老容易被滥用老项目可维护但必须存密文MMKV性能好API 简单仍然只是存储容器已有项目可继续用但必须存密文DataStore协程 / Flow 友好新项目推荐接入成本稍高新项目优先但仍必须存密文我的建议是新项目 DataStore AES-GCM Android Keystore 老项目已大量使用 MMKV MMKV AES-GCM Android Keystore 老项目还在 SharedPreferences 先封装 加密存储再逐步迁移 安全核心 不是 DataStore / MMKV / SharedPreferences而是 AES-GCM Android Keystore八、不要让业务代码直接依赖具体存储很多项目最容易出问题的地方不是选错了 DataStore 或 MMKV而是 Token 读写散落在各处。比如页面里直接读MMKV.defaultMMKV().decodeString(accessToken)ViewModel 里直接写sharedPreferences.edit() .putString(token, token) .apply()拦截器里直接读 DataStorerunBlocking { context.dataStore.data.first() }这些写法都会带来问题Token 读写不收口 后面改加密很麻烦 后面换存储很麻烦 日志脱敏不好统一 退出登录清理容易漏 拦截器承担了太多职责更好的结构是AuthInterceptor ↓ TokenManager ↓ SecureTokenStore ↓ LocalKeyValueStore ↓ DataStore / MMKV / SharedPreferences职责要分清楚AuthInterceptor 只负责给请求加 Authorization Header。 TokenManager 负责登录态管理比如保存 Token、读取 accessToken、清空 Token。 SecureTokenStore 负责 Token 的安全存取内部处理加密和解密。 LocalKeyValueStore 屏蔽 DataStore / MMKV / SharedPreferences 差异。 DataStore / MMKV / SharedPreferences 具体本地存储实现。这样后面无论你换底层存储还是升级加密方案上层业务都不需要大改。九、抽象一个 LocalKeyValueStore可以先定义一个简单接口interface LocalKeyValueStore { suspend fun putString(key: String, value: String) suspend fun getString(key: String): String? suspend fun remove(key: String) suspend fun clear() }然后提供不同实现DataStoreLocalKeyValueStore MmkvLocalKeyValueStore SharedPreferencesLocalKeyValueStore这样 SecureTokenStore 不需要知道底层到底是什么。它只关心保存密文 读取密文 删除密文这就是抽象的价值。十、DataStore 实现示例Preferences DataStore 可以这样封装class DataStoreLocalKeyValueStore( private val dataStore: DataStorePreferences ) : LocalKeyValueStore { override suspend fun putString(key: String, value: String) { val prefKey stringPreferencesKey(key) dataStore.edit { preferences - preferences[prefKey] value } } override suspend fun getString(key: String): String? { val prefKey stringPreferencesKey(key) return dataStore.data .map { preferences - preferences[prefKey] } .firstOrNull() } override suspend fun remove(key: String) { val prefKey stringPreferencesKey(key) dataStore.edit { preferences - preferences.remove(prefKey) } } override suspend fun clear() { dataStore.edit { preferences - preferences.clear() } } }创建 DataStoreval Context.authDataStore: DataStorePreferences by preferencesDataStore( name auth_store )然后注入val localStore DataStoreLocalKeyValueStore(context.authDataStore)十一、MMKV 实现示例如果项目里继续使用 MMKV也可以实现同一个接口class MmkvLocalKeyValueStore( private val mmkv: MMKV ) : LocalKeyValueStore { override suspend fun putString(key: String, value: String) { mmkv.encode(key, value) } override suspend fun getString(key: String): String? { return mmkv.decodeString(key, null) } override suspend fun remove(key: String) { mmkv.removeValueForKey(key) } override suspend fun clear() { mmkv.clearAll() } }这里虽然方法是suspend但 MMKV 本身不是协程挂起式 API。这么设计只是为了让上层接口统一方便以后切换 DataStore。十二、SharedPreferences 实现示例老项目也可以先包一层class SharedPreferencesLocalKeyValueStore( private val sharedPreferences: SharedPreferences ) : LocalKeyValueStore { override suspend fun putString(key: String, value: String) { sharedPreferences.edit() .putString(key, value) .apply() } override suspend fun getString(key: String): String? { return sharedPreferences.getString(key, null) } override suspend fun remove(key: String) { sharedPreferences.edit() .remove(key) .apply() } override suspend fun clear() { sharedPreferences.edit() .clear() .apply() } }这样即使暂时不迁移 DataStore也可以先完成 Token 存储结构收口。十三、SecureTokenStore 只面对 LocalKeyValueStoreToken 安全存储可以这样写class SecureTokenStoreImpl( private val localStore: LocalKeyValueStore, private val cryptoManager: CryptoManager, private val json: Json ) : SecureTokenStore { companion object { private const val KEY_TOKEN_IV token_iv private const val KEY_TOKEN_CIPHER_TEXT token_cipher_text } override suspend fun saveToken(token: TokenEntity) { val tokenJson json.encodeToString(token) val encryptedValue cryptoManager.encrypt(tokenJson) localStore.putString(KEY_TOKEN_IV, encryptedValue.iv) localStore.putString(KEY_TOKEN_CIPHER_TEXT, encryptedValue.cipherText) } override suspend fun getToken(): TokenEntity? { val iv localStore.getString(KEY_TOKEN_IV) val cipherText localStore.getString(KEY_TOKEN_CIPHER_TEXT) if (iv.isNullOrBlank() || cipherText.isNullOrBlank()) { return null } return runCatching { val tokenJson cryptoManager.decrypt( EncryptedValue( iv iv, cipherText cipherText ) ) json.decodeFromStringTokenEntity(tokenJson) }.getOrElse { clearToken() null } } override suspend fun clearToken() { localStore.remove(KEY_TOKEN_IV) localStore.remove(KEY_TOKEN_CIPHER_TEXT) } }这里最关键的是SecureTokenStore 不关心底层是 DataStore、MMKV还是 SharedPreferences。 它只关心能不能保存密文、读取密文、删除密文。十四、TokenManager 负责登录态管理上面 SecureTokenStore 只负责安全存取。再往上应该有 TokenManager 管理登录态。class TokenManager( private val secureTokenStore: SecureTokenStore ) { Volatile private var memoryToken: TokenEntity? null suspend fun saveToken(token: TokenEntity) { memoryToken token secureTokenStore.saveToken(token) } suspend fun restoreToken(): TokenEntity? { val token secureTokenStore.getToken() memoryToken token return token } fun getAccessTokenFromMemory(): String? { return memoryToken?.accessToken } suspend fun getAccessToken(): String? { memoryToken?.let { return it.accessToken } return restoreToken()?.accessToken } suspend fun clearToken() { memoryToken null secureTokenStore.clearToken() } }为什么要维护内存 Token因为 OkHttp Interceptor 是同步接口而 DataStore 是协程和 Flow 风格。如果每次请求都从 DataStore 读取、解密结构会比较别扭。更推荐App 启动时 从本地密文恢复 Token 到内存。 登录成功时 Token 加密保存到本地同时放入内存。 请求接口时 拦截器优先从内存拿 accessToken。 退出登录时 清空内存 Token 和本地密文 Token。十五、OkHttp 拦截器不要直接读 DataStore错误方向class AuthInterceptor( private val context: Context ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val token runBlocking { context.dataStore.data .map { it[ACCESS_TOKEN] } .firstOrNull() } val request chain.request() .newBuilder() .header(Authorization, Bearer $token) .build() return chain.proceed(request) } }这种写法的问题是拦截器直接依赖 DataStore 拦截器承担了存储细节 runBlocking 容易造成阻塞 加密解密逻辑容易散落 后续换 MMKV 或 SharedPreferences 成本高更推荐class AuthInterceptor( private val tokenManager: TokenManager ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val accessToken tokenManager.getAccessTokenFromMemory() val request if (accessToken.isNullOrBlank()) { chain.request() } else { chain.request() .newBuilder() .header(Authorization, Bearer $accessToken) .build() } return chain.proceed(request) } }拦截器只做一件事加 Header不要让它变成存储读取器 加密解密器 登录态管理器十六、accessToken 和 refreshToken 的存储策略accessToken 和 refreshToken 不应该完全同等对待。accessToken - 有效期短 - 使用频繁 - 可以放内存 - 可以加密持久化 - App 冷启动后从本地密文恢复 refreshToken - 有效期长 - 使用频率低 - 价值更高 - 必须加密持久化 - 退出登录必须清理 - 刷新失败必须清理比较常见的折中方案是accessToken 和 refreshToken 都加密持久化。 accessToken 同时放内存方便拦截器快速读取。如果安全要求更高也可以accessToken 只放内存。 refreshToken 加密持久化。 App 冷启动后用 refreshToken 换新的 accessToken。这个要结合业务体验和安全要求决定。十七、迁移策略不要一刀切如果老项目现在是SharedPreferences 明文 Token或者MMKV 明文 Token不要一上来就全项目大规模替换 DataStore。更稳的迁移方式是第一步封装 TokenManager收口 Token 存取。 第二步新增 SecureTokenStore实现 Token 加密存储。 第三步读取新加密 Token如果没有再读取旧明文 Token。 第四步读到旧明文 Token 后加密保存到新位置。 第五步删除旧明文 Token。 第六步多个版本后再清理旧逻辑。迁移伪代码class TokenMigration( private val oldPlainTokenStore: OldPlainTokenStore, private val secureTokenStore: SecureTokenStore ) { suspend fun migrateIfNeeded() { val newToken secureTokenStore.getToken() if (newToken ! null) { return } val oldToken oldPlainTokenStore.getToken() if (oldToken ! null) { secureTokenStore.saveToken(oldToken) oldPlainTokenStore.clear() } } }这个迁移逻辑非常重要。它影响老用户升级后的登录态体验。十八、KMP 方向怎么考虑如果后续要做 KMP / CMPToken 存储更应该提前抽象。可以在 commonMain 定义interface SecureKeyValueStore { suspend fun putString(key: String, value: String) suspend fun getString(key: String): String? suspend fun remove(key: String) suspend fun clear() }Android 实现可以是DataStore MMKV SharedPreferences AES-GCM KeystoreiOS 实现可以是Keychain UserDefaults 加密上层 TokenManager 不关心平台差异。它只依赖 SecureTokenStore。所以如果未来考虑 KMP越早抽象越好。不要让业务层到处直接依赖 Android 具体存储 API。十九、最终建议1. 新项目推荐DataStore AES-GCM Android Keystore原因更符合 Kotlin / 协程 / Flow 体系 更适合 Compose 和现代 Android 架构 后续和 KMP 方向更顺2. 老项目已经大量使用 MMKV推荐MMKV AES-GCM Android Keystore原因不强行替换 先把安全问题解决 后续再考虑抽象和迁移3. 老项目还在用 SharedPreferences推荐先封装 加密存储 再逐步迁移 DataStore不要第一步就大规模替换。4. 不推荐不推荐SharedPreferences 明文存 Token MMKV 明文存 Token DataStore 明文存 Token SQLite 明文存 Token 普通文件明文存 Token 业务代码到处直接读写 Token 拦截器直接读本地存储二十、最终落地清单这篇文章可以落成一个清单1. DataStore / MMKV / SharedPreferences 都只是存储容器。 2. Token 安全核心是先加密再存储。 3. Token 使用 AES-GCM 加密。 4. AES key 使用 Android Keystore 生成和保护。 5. 新项目优先 DataStore。 6. 老项目已有 MMKV可以继续 MMKV。 7. 老项目 SharedPreferences 不要一刀切先封装再迁移。 8. 抽象 LocalKeyValueStore屏蔽底层存储差异。 9. SecureTokenStore 负责加密后的 Token 存取。 10. TokenManager 负责登录态管理和内存缓存。 11. OkHttp Interceptor 不要直接读 DataStore / MMKV。 12. accessToken 可以内存缓存refreshToken 必须重点保护。 13. 老版本明文 Token 要做迁移。 14. 退出登录、刷新失败时必须清 Token。 15. 日志、Crash、OkHttp Header 必须脱敏。二十一、总结Token 到底应该存 DataStore、MMKV还是 SharedPreferences答案不是绝对的。更准确地说新项目优先 DataStore。 老项目已有 MMKV可以继续 MMKV。 老项目还在 SharedPreferences可以先封装再迁移。 但无论用哪个都不能明文存 Token。真正的安全结构是Token 明文 ↓ AES-GCM 加密 ↓ Token 密文 ↓ DataStore / MMKV / SharedPreferences AES key ↓ Android Keystore 保护如果压缩成一句话DataStore、MMKV、SharedPreferences 只是柜子AES-GCM 才是锁Android Keystore 才是保管钥匙的地方。再工程化一点先把 TokenManager 收口再做 SecureTokenStore 加密存储最后再决定底层用 DataStore、MMKV 还是 SharedPreferences。这样设计后面无论你做 OkHttp 401 刷新、日志脱敏、退出登录清理还是 KMP 跨平台都会更稳。

相关新闻