前两篇我们已经讲清楚了两个问题:
第一篇讲的是:
App 登录时密码到底要不要加密?为什么通常走 HTTPS?
第二篇讲的是:
Token 拿到后,为什么不能明文存?
这一篇开始进入真正的落地方案。
如果 App 登录成功后,后端返回:
{
"accessToken": "xxx",
"refreshToken": "yyy"
}
那么 Android 端比较合理的本地安全存储方案是:
Token 明文
↓
AES-GCM 加密
↓
Token 密文
↓
存到 DataStore / MMKV / SharedPreferences
AES key
↓
Android Keystore 生成和保护
一句话总结:
Token 存密文,AES key 进 Keystore。
这篇文章就专门讲清楚:
1. AES-GCM 负责什么?
2. Android Keystore 负责什么?
3. Token 为什么不是直接存进 Keystore?
4. Android 端代码结构应该怎么拆?
5. 拦截器里怎么取 Token?
6. 老版本明文 Token 怎么迁移?
一、先把整体关系画出来
Token 本地安全存储不是单独靠某一个组件完成的。
它是三层配合:
Android Keystore
↓
保护 AES key
AES-GCM
↓
用 AES key 加密 / 解密 Token
DataStore / MMKV / SharedPreferences
↓
保存加密后的 Token 密文
所以不要理解成:
用了 DataStore,Token 就安全了。
用了 MMKV,Token 就安全了。
用了 Keystore,Token 就直接放进去了。
正确理解是:
DataStore / MMKV:只是存储容器
AES-GCM:真正加密 Token
Android Keystore:保护 AES key
也就是说,本地文件里保存的是密文,不是原始 Token。
二、为什么 Token 不直接存进 Keystore?
很多人第一次听到 Android Keystore,会以为:
那我直接把 Token 存进 Keystore 不就行了?
这个理解不准确。
Android Keystore 的核心作用不是存业务数据,而是存和保护加密密钥。
更准确地说:
Keystore 存 key
AES 用 key 加密 Token
Token 密文存在本地存储
也就是:
Android Keystore
↓
AES key
AES key
↓
加密 Token
Token 密文
↓
DataStore / MMKV / SharedPreferences
所以这句话非常关键:
Keystore 不是用来存 Token 的,Keystore 是用来保护加密 Token 的密钥。
三、为什么选择 AES-GCM?
Token 是一段后续还要拿出来使用的凭证。
比如:
Authorization: Bearer accessToken
所以 Token 不能用 MD5 / SHA 处理。
因为 MD5 / SHA 是摘要,不能还原。
Token 本地存储需要的是:
可以加密
也可以解密
所以要使用对称加密算法。
AES 就是常见的对称加密算法。
而在 Android 本地加密 Token 这种场景下,推荐使用:
AES/GCM/NoPadding
为什么是 GCM?
因为 GCM 不只是加密,还能校验密文有没有被篡改。
可以理解为:
AES-GCM = 加密 + 完整性校验
如果本地密文被人改了,解密时会失败。
四、不要使用 AES/ECB
很多老代码里可能会看到:
AES/ECB/PKCS5Padding
这个不建议使用。
ECB 模式的问题是:相同明文块会加密出相同密文块,容易暴露数据模式。
Token 这种敏感数据存储,不要使用 ECB。
建议统一使用:
AES/GCM/NoPadding
同时,每次加密都要使用新的 IV。
常见做法:
AES key:由 Android Keystore 生成
IV:每次加密随机生成,一般 12 字节
cipherText:AES-GCM 加密后的密文
最终本地保存:
{
"iv": "base64 iv",
"cipherText": "base64 cipherText"
}
IV 不需要保密,但同一个 key 下 IV 不能重复。
五、AES key 不能写死
错误做法:
private const val AES_KEY = "1234567890123456"
这类写法非常危险。
因为 APK 可以被反编译。
如果 AES key 写死在代码里,攻击者拿到 APK 后,就有机会分析出 key。
一旦 key 被拿到,本地密文 Token 也就可能被解开。
所以 AES key 应该由 Android Keystore 生成和保护。
正确方向是:
App 第一次需要加密 Token
↓
检查 Keystore 里有没有 AES key
↓
没有就生成一个
↓
后续都通过这个 key 加解密 Token
六、Android Keystore 和 APK 签名 keystore 不是一个东西
这里必须单独说一次。
Android 开发里有两个容易混的 Keystore。
第一个是打包签名用的:
debug.keystore
release.jks
xxx.keystore
它用于:
APK / AAB 签名
应用升级校验
第三方平台 SHA1 / SHA256 配置
第二个是本文说的:
Android Keystore System
它用于:
App 运行时生成和保护加密密钥
所以:
APK 签名 keystore:发布体系
Android Keystore System:运行时密钥保护体系
这两个不是一个东西。
Token 本地加密用的是第二个。
七、推荐的代码结构
不要把加密逻辑、存储逻辑、拦截器逻辑写在一起。
推荐拆成四层:
TokenManager
↓
SecureTokenStore
↓
CryptoManager
↓
KeystoreKeyManager
每一层职责不同:
TokenManager:
管理登录态,比如保存 Token、读取 accessToken、清空 Token。
SecureTokenStore:
负责 Token 的安全存储,对外不暴露明文落地细节。
CryptoManager:
负责 AES-GCM 加密和解密。
KeystoreKeyManager:
负责从 Android Keystore 获取或创建 AES key。
OkHttp 拦截器只依赖 TokenManager。
它不应该直接关心 DataStore、MMKV、AES、Keystore。
八、定义 Token 数据结构
业务上可以先定义一个 TokenEntity:
data class TokenEntity(
val accessToken: String,
val refreshToken: String,
val expiresAt: Long
)
加密后保存的数据可以这样定义:
data class EncryptedValue(
val iv: String,
val cipherText: String
)
如果 accessToken 和 refreshToken 分开加密,可以分别保存:
data class EncryptedTokenEntity(
val accessTokenIv: String,
val accessTokenCipherText: String,
val refreshTokenIv: String,
val refreshTokenCipherText: String,
val expiresAt: Long
)
注意:
expiresAt 是否加密,看安全要求。
如果只是判断过期时间,单独保存问题不大。
但如果你希望整个 TokenEntity 都作为敏感数据,也可以把整个 JSON 序列化后一起加密。
九、KeystoreKeyManager:生成和获取 AES key
核心代码大概如下:
class KeystoreKeyManager {
companion object {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "token_aes_key"
}
fun getOrCreateSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
load(null)
}
val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
if (existingKey != null) {
return existingKey.secretKey
}
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE
)
val keySpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setKeySize(256)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
keyGenerator.init(keySpec)
return keyGenerator.generateKey()
}
}
这段代码做了几件事:
1. 打开 Android Keystore
2. 判断是否已经存在 token_aes_key
3. 如果存在,直接返回
4. 如果不存在,生成一个 AES key
5. 限制它只能用于 GCM 模式和无填充
这里的 key 不会像普通字符串那样保存在代码里。
它由系统 Keystore 管理。
十、CryptoManager:AES-GCM 加密和解密
加密逻辑:
class CryptoManager(
private val keyManager: KeystoreKeyManager
) {
companion object {
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_SIZE = 12
}
fun encrypt(plainText: String): EncryptedValue {
val secretKey = keyManager.getOrCreateSecretKey()
val iv = ByteArray(IV_SIZE)
SecureRandom().nextBytes(iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec)
val cipherText = cipher.doFinal(
plainText.toByteArray(Charsets.UTF_8)
)
return EncryptedValue(
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
cipherText = Base64.encodeToString(cipherText, Base64.NO_WRAP)
)
}
fun decrypt(encryptedValue: EncryptedValue): String {
val secretKey = keyManager.getOrCreateSecretKey()
val iv = Base64.decode(encryptedValue.iv, Base64.NO_WRAP)
val cipherText = Base64.decode(encryptedValue.cipherText, Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
val plainText = cipher.doFinal(cipherText)
return plainText.toString(Charsets.UTF_8)
}
}
这里有几个关键点:
1. 每次 encrypt 都生成新的 IV
2. IV 和 cipherText 都用 Base64 转成字符串存储
3. decrypt 时需要同一个 IV 和 cipherText
4. 如果密文被篡改,decrypt 会失败
Base64 不是加密。
Base64 只是为了把二进制数据变成字符串,方便存到 DataStore / MMKV / SharedPreferences 里。
十一、SecureTokenStore:加密保存 Token
这里先用接口表达,不绑定具体存储框架。
interface SecureTokenStore {
suspend fun saveToken(token: TokenEntity)
suspend fun getToken(): TokenEntity?
suspend fun clearToken()
}
然后具体实现里负责:
保存时:
TokenEntity → JSON → AES-GCM 加密 → 保存密文
读取时:
读取密文 → AES-GCM 解密 → JSON → TokenEntity
伪代码:
class SecureTokenStoreImpl(
private val cryptoManager: CryptoManager,
private val localStore: LocalKeyValueStore,
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.decodeFromString<TokenEntity>(tokenJson)
}.getOrNull()
}
override suspend fun clearToken() {
localStore.remove(KEY_TOKEN_IV)
localStore.remove(KEY_TOKEN_CIPHER_TEXT)
}
}
这里的 localStore 可以是:
DataStore
MMKV
SharedPreferences
下一篇可以专门讲 DataStore、MMKV、SharedPreferences 怎么选。
这一篇先记住:
localStore 只存密文,不存 Token 明文。
十二、LocalKeyValueStore:屏蔽底层存储
为了后续方便切换 DataStore / MMKV,可以抽一个接口:
interface LocalKeyValueStore {
suspend fun putString(key: String, value: String)
suspend fun getString(key: String): String?
suspend fun remove(key: String)
suspend fun clear()
}
这样 Token 存储上层不关心底层到底是 DataStore 还是 MMKV。
后续可以有不同实现:
DataStoreLocalKeyValueStore
MmkvLocalKeyValueStore
SharedPreferencesLocalKeyValueStore
这就是工程设计里的隔离。
不要让 TokenManager 直接依赖 MMKV 或 DataStore。
十三、TokenManager:统一管理登录态
TokenManager 负责业务层面的 Token 管理。
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()
}
}
为什么要有内存缓存?
因为 OkHttp Interceptor 是同步接口,而 DataStore 是协程 / Flow 风格。
为了避免每次请求都阻塞读取本地存储,可以在登录成功或 App 启动时,把 Token 从本地恢复到内存。
请求时优先从内存拿 accessToken。
十四、OkHttp 拦截器里怎么使用?
拦截器不应该直接解密 Token。
它只负责:
向 TokenManager 要 accessToken
然后加到请求头
示例:
class AuthInterceptor(
private val tokenManager: TokenManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val accessToken = tokenManager.getAccessTokenFromMemory()
val newRequest = if (accessToken.isNullOrBlank()) {
request
} else {
request.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
}
return chain.proceed(newRequest)
}
}
这里要注意:
Header 里放的是原始 accessToken,不是密文。
因为后端只认识原始 Token。
安全点在于:
本地落地保存的是密文。
运行时短暂使用明文。
传输过程走 HTTPS。
日志里不打印 Token。
十五、App 启动时恢复 Token
App 启动时可以做一次恢复:
class AppInitializer(
private val tokenManager: TokenManager
) {
suspend fun init() {
tokenManager.restoreToken()
}
}
实际项目里,可以在启动页、Application 初始化流程、或者登录态初始化模块里处理。
流程是:
App 启动
↓
从本地读取 Token 密文
↓
用 Keystore 里的 AES key 解密
↓
恢复 Token 到内存
↓
后续请求拦截器从内存拿 accessToken
这样拦截器逻辑就比较干净。
十六、退出登录时清理 Token
退出登录时,不要只跳转登录页。
应该完整清理:
1. 调后端 logout 接口
2. 后端让 refreshToken 失效
3. 客户端清空内存 Token
4. 客户端清空本地密文 Token
5. 清空用户信息缓存
6. 跳转登录页
客户端代码示例:
class LogoutUseCase(
private val authApi: AuthApi,
private val tokenManager: TokenManager,
private val userStore: UserStore
) {
suspend fun logout() {
runCatching {
authApi.logout()
}
tokenManager.clearToken()
userStore.clear()
}
}
注意:
即使 logout 接口失败,本地 Token 也要清掉。
因为用户点击退出后,客户端登录态必须退出。
十七、老版本明文 Token 迁移
如果项目之前是明文保存 Token,现在改成 AES-GCM + Keystore,必须考虑老版本迁移。
流程:
1. 先尝试读取新版本加密 Token
2. 如果没有,再读取旧版本明文 Token
3. 如果读到了旧 Token,就加密保存到新位置
4. 删除旧 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()
}
}
}
这个迁移逻辑很重要。
否则用户升级 App 后,可能登录态直接丢失。
十八、异常情况怎么处理?
Token 解密可能失败。
比如:
1. 密文被篡改
2. Keystore key 丢失
3. 用户恢复了旧备份
4. App 数据异常
5. 系统安全模块异常
解密失败时,不要崩溃。
可以统一处理成:
1. 清空本地 Token
2. 清空内存 Token
3. 回到登录页
4. 必要时上报非敏感错误日志
示例:
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, cipherText)
)
json.decodeFromString<TokenEntity>(tokenJson)
}.getOrElse {
clearToken()
null
}
}
这里不要把密文、Token、异常上下文里的敏感内容打进日志。
十九、日志脱敏必须一起做
Token 加密存储做完后,还要处理日志。
否则会出现:
本地存储是密文
但 OkHttp 日志里打印了 Authorization
这就等于绕过了本地加密。
至少要处理:
Authorization
accessToken
refreshToken
Cookie
password
验证码
OkHttp 日志拦截器里应该脱敏 Header:
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
redactHeader("Authorization")
redactHeader("Cookie")
}
自己的日志系统也要加统一脱敏:
object SensitiveMasker {
fun mask(input: String): String {
return input
.replace(Regex("Bearer\\s+[A-Za-z0-9._\\-]+"), "Bearer ***")
.replace(Regex("\"accessToken\"\\s*:\\s*\"[^\"]+\""), "\"accessToken\":\"***\"")
.replace(Regex("\"refreshToken\"\\s*:\\s*\"[^\"]+\""), "\"refreshToken\":\"***\"")
.replace(Regex("\"password\"\\s*:\\s*\"[^\"]+\""), "\"password\":\"***\"")
}
}
安全设计不是只防黑客,也要防自己把敏感数据打印出去。
二十、这套方案能不能绝对安全?
不能。
移动端没有绝对安全。
即使用了:
AES-GCM
Android Keystore
DataStore
日志脱敏
HTTPS
在 Root、Hook、Frida、Xposed、动态调试环境下,攻击者仍然可能在运行时拿到 Token。
因为 App 最终要把 accessToken 解密出来,放到 Authorization Header 里。
所以客户端安全的目标不是:
永远不可能泄漏。
而是:
尽量不泄漏。
泄漏后尽快失效。
服务端能识别异常。
因此还需要后端配合:
accessToken 短有效期
refreshToken 存 Redis
refreshToken 轮换
登出删除 refreshToken
改密码后全端失效
后台踢设备
deviceId 绑定
jti 标识 Token
tokenVersion 控制旧 Token 失效
客户端负责防泄漏,后端负责可失效。
这才是完整闭环。
二十一、最终落地清单
这篇文章的落地清单如下:
1. 不再明文保存 accessToken / refreshToken。
2. 使用 AES-GCM 加密 Token。
3. AES key 由 Android Keystore 生成和保护。
4. DataStore / MMKV / SharedPreferences 只保存密文和 IV。
5. TokenManager 统一收口 Token 存取。
6. OkHttp 拦截器只从 TokenManager 获取 accessToken。
7. accessToken 可以内存缓存,refreshToken 必须加密持久化。
8. App 启动时从本地密文恢复 Token 到内存。
9. 退出登录时清空内存 Token 和本地密文 Token。
10. 老版本明文 Token 要迁移到新加密存储。
11. 解密失败时清空 Token 并回登录态失效流程。
12. OkHttp、本地日志、Crash 上报全部做 Token 脱敏。
13. 后端配合 Token 失效、refreshToken 轮换、踢下线。
二十二、总结
Android Token 本地安全存储的核心不是:
到底用 DataStore 还是 MMKV?
而是:
Token 不能明文落地。
真正的安全结构是:
Android Keystore
↓
保护 AES key
AES-GCM
↓
加密 Token
DataStore / MMKV
↓
保存 Token 密文
所以本文最重要的一句话是:
Token 存密文,AES key 进 Keystore。
再完整一点:
本地保存的是密文,运行时短暂解密,网络传输走 HTTPS,日志系统做脱敏,服务端负责 Token 失效和风险兜底。
这才是 Android 端比较完整的 Token 本地安全存储方案。