移动端登录态安全设计(2):Token 拿到后,为什么不能明文存?

📅 2026/7/13 16:20:07
移动端登录态安全设计(2):Token 拿到后,为什么不能明文存?
上一篇移动端登录态安全设计1App 登录时密码到底要不要加密为什么通常走 HTTPS_前端登录输入密码 传输到后端,建议加密吗-CSDN博客文章讲的是App 登录时密码到底要不要加密为什么通常走 HTTPS结论是登录阶段客户端通常通过 HTTPS 把用户输入的原始密码传给后端后端负责密码校验和安全存储。但登录成功之后新的问题来了。后端会返回{ accessToken: xxx, refreshToken: yyy }那么 App 拿到 Token 后到底应该怎么存很多项目里常见的写法是sp.edit() .putString(accessToken, accessToken) .putString(refreshToken, refreshToken) .apply()或者mmkv.encode(accessToken, accessToken) mmkv.encode(refreshToken, refreshToken)这样写能跑拦截器里也能正常取出来加到请求头里。但从安全角度看这就是Token 明文存储。这篇文章就专门讲清楚Token 拿到后为什么不能明文存如果不能明文存移动端到底应该怎么处理一、Token 本质上是什么很多人会把 Token 理解成一个普通字符串。但其实 Token 不是普通字符串。它代表的是用户登录后的身份凭证。比如请求用户信息接口时GET /user/info Authorization: Bearer accessToken后端看到这个 accessToken并校验通过就会认为这个请求来自某个已经登录的用户。所以 Token 的本质是登录凭证甚至可以更直接地说Token 在有效期内就相当于“已登录身份”。如果攻击者拿到了有效 Token就可能绕过账号密码直接以用户身份请求接口。二、accessToken 和 refreshToken 不一样一般企业级登录体系里不建议只返回一个 Token而是返回两个{ accessToken: 短期访问令牌, refreshToken: 长期刷新令牌 }这两个职责不同。1. accessTokenaccessToken 用来访问业务接口Authorization: Bearer accessToken它的特点是使用频繁 暴露面大 有效期短 泄漏后影响相对有限一般可以设置为15 分钟 30 分钟 2 小时具体时长看业务安全等级。2. refreshTokenrefreshToken 用来刷新 accessToken。流程是accessToken 过期 ↓ App 用 refreshToken 请求刷新接口 ↓ 后端返回新的 accessToken / refreshTokenrefreshToken 的特点是使用频率低 有效期更长 价值更高 一旦泄漏风险更大为什么因为 accessToken 泄漏了最多用到过期。但 refreshToken 泄漏了攻击者可能不断刷新新的 accessToken。所以在移动端本地存储时refreshToken 比 accessToken 更需要重点保护。三、什么叫 Token 明文存储所谓明文存储就是 Token 以原始字符串形式直接落到本地文件里。比如sharedPreferences.edit() .putString(token, token) .apply()或者mmkv.encode(token, token)或者把 Token 存到 SQLitetoken eyJhbGciOiJIUzI1NiJ9.xxxxxx甚至写到普通文件/data/data/包名/files/token.txt只要本地文件里保存的是原始 Token就属于明文存储。注意SharedPreferences 明文存 Token是明文存储。 MMKV 明文存 Token也是明文存储。 DataStore 明文存 Token仍然是明文存储。 SQLite 明文存 Token也还是明文存储。DataStore、MMKV、SharedPreferences 本身只是“存储容器”。它们不等于安全加密。四、为什么不能明文存 Token原因很简单本地文件不是绝对安全的。移动端运行环境比后端复杂很多。可能存在Root 设备 Hook 工具 Frida Xposed 调试包 备份文件 日志文件 崩溃上报 恶意软件 二次打包 本地文件被导出即使普通 App 的私有目录默认不能被其他普通 App 直接访问也不能因此认为 Token 明文存储就是安全的。因为安全设计不能只考虑“正常设备、正常用户、正常环境”。企业级设计要考虑的是如果本地文件被拿到Token 是否会直接暴露如果 Token 是明文存储答案就是会。五、Token 明文存储会带来什么风险1. 攻击者可以直接拿 Token 请求接口假设本地存了accessToken abc.xxx.yyy攻击者拿到后可以直接构造请求GET /user/info Authorization: Bearer abc.xxx.yyy只要 Token 没过期后端校验通过就会认为这是合法用户请求。2. refreshToken 泄漏风险更大如果泄漏的是 refreshToken问题更严重。攻击者可以请求POST /auth/refresh拿到新的 accessToken。所以 refreshToken 不应该明文存在本地。它是整个登录态里最需要保护的客户端凭证。3. 日志和崩溃上报可能二次泄漏即使本地存储做了加密如果日志里打印了 Token仍然会泄漏。例如Log.d(Token, accessToken $accessToken)或者 OkHttp 日志打印Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxxxxx再比如 Crash 上报时带出了请求参数、Header、用户信息。这些都会让 Token 从另一个通道泄漏出去。所以 Token 安全不是只改存储还要处理日志。六、正确方向Token 本地存密文Token 不能明文存那应该怎么存核心思路是Token 明文 ↓ AES-GCM 加密 ↓ Token 密文 ↓ 存到 DataStore / MMKV / SharedPreferences也就是说本地文件里保存的不是原始 Token而是加密后的密文。比如本地看到的应该是{ accessToken: Base64后的密文, refreshToken: Base64后的密文, iv: Base64后的IV }而不是{ accessToken: eyJhbGciOiJIUzI1NiJ9.xxxxxx, refreshToken: refresh_xxxxxx }七、为什么是 AES-GCMAES 是对称加密算法适合加密本地小数据比如 Token。但不要随便写AES/ECB/PKCS5PaddingECB 模式不安全不推荐用于这种场景。更推荐的是AES/GCM/NoPaddingAES-GCM 的好处是既能加密数据 又能校验数据是否被篡改所以 Token 本地加密可以理解成加密别人看不到 Token 明文 认证别人篡改密文后解密会失败八、AES key 不能写死在代码里有些人会写private const val AES_KEY 1234567890123456这不安全。因为 APK 可以反编译。一旦 key 写死在代码里攻击者拿到 APK 后就可能分析出这个 key。所以 AES key 不能硬编码。正确思路是AES key 由 Android Keystore 生成和保护。也就是说Android Keystore保护 AES key AES-GCM用 AES key 加密 Token DataStore / MMKV保存 Token 密文这里一定要分清楚Token 不是直接存进 Keystore。 AES key 才是交给 Keystore 管。 Token 是被 AES 加密后存到本地存储里。一句话Token 存密文密钥进 Keystore。九、Android Keystore 和 APK 签名 keystore 不是一个东西这里很容易混。很多 Android 开发一听 Keystore第一反应是debug.keystore release.jks这是打包签名用的 keystore。它的作用是给 APK / AAB 签名 证明 App 是谁发布的 保证后续升级包签名一致 给高德、微信、支付宝等平台配置 SHA1 / SHA256但 Token 加密里说的是Android Keystore System它是 App 运行时使用的系统能力。作用是生成和保存加密密钥 让密钥更难被导出 限制密钥用途和使用条件所以这两个东西不是一回事APK 签名 keystore 打包发布用 Android Keystore System 运行时保护加密密钥用十、DataStore、MMKV、SharedPreferences 到底选谁很多人会问Token 到底存 DataStore、MMKV还是 SharedPreferences我的建议是新项目 / Compose / 协程 / Flow 优先 DataStore 老项目 / 已经大量使用 MMKV 可以继续 MMKV SharedPreferences 老项目可以维护新项目不建议继续作为主力方案但这里要强调选择 DataStore 不代表 Token 自动安全。 选择 MMKV 也不代表 Token 自动安全。真正的安全点是先加密再存储。也就是Token 明文 ↓ AES-GCM 加密 ↓ 密文 ↓ DataStore / MMKV 保存DataStore 和 MMKV 只是保存密文的地方。十一、拦截器里怎么用 Token很多项目里已经有这种逻辑class AuthInterceptor( private val tokenManager: TokenManager ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request chain.request() val accessToken tokenManager.getAccessToken() val newRequest if (accessToken.isNullOrBlank()) { request } else { request.newBuilder() .header(Authorization, Bearer $accessToken) .build() } return chain.proceed(newRequest) } }这个思路本身没问题。需要改的是 TokenManager 后面的存取逻辑。以前可能是TokenManager ↓ SharedPreferences 明文读取 Token现在应该改成TokenManager ↓ SecureTokenStore ↓ 读取 Token 密文 ↓ CryptoManager 解密 ↓ 返回 accessToken 明文给拦截器也就是说本地存储密文 运行时使用明文 请求传输HTTPS Authorization Header注意不是把加密后的 Token 放到 Header 里。后端只认识原始 Token。所以请求时仍然是Authorization: Bearer accessToken明文只是这个明文 Token 只在运行时短暂出现不应该明文落地保存。十二、不要用 MD5 处理 TokenToken 后续还要拿出来使用。所以不能这样val tokenHash md5(token) save(tokenHash)因为 MD5 是摘要不可逆。你存了 MD5 之后就拿不回原始 Token 了。Token 本地存储需要的是可以加密 也可以解密所以应该用 AES-GCM 这种对称加密而不是 MD5 这种摘要算法。这里可以这样记密码 后端只需要验证不需要还原所以用哈希。 Token 客户端后面还要拿出来请求接口所以要用加密。十三、accessToken 和 refreshToken 可以采用不同策略实际项目里可以这样处理accessToken - 有效期短 - 可以放内存 - 也可以加密持久化 - App 重启后从加密存储恢复 refreshToken - 有效期长 - 必须加密持久化 - 退出登录必须清理 - 刷新失败必须清理如果你要做得更稳可以让 accessToken 运行时优先走内存缓存。比如登录成功 1. Token 加密保存到本地 2. accessToken 同时放到内存变量 请求接口 1. 拦截器优先从内存拿 accessToken 2. 内存没有再从本地密文解密恢复 App 冷启动 1. 从本地读取密文 2. 解密得到 Token 3. 恢复到内存这样可以减少频繁解密也方便和 OkHttp 拦截器这种同步接口配合。十四、老版本明文 Token 怎么迁移如果项目以前已经明文存 Token现在要改成加密存储不能直接粗暴替换。否则老用户升级 App 后可能登录态丢失。可以做一次迁移1. 先读取新版本加密 Token 2. 如果新 Token 不存在再读取旧版本明文 Token 3. 如果读到旧 Token就加密保存到新位置 4. 删除旧明文 Token伪代码suspend fun migrateIfNeeded() { val encryptedToken secureTokenStore.getToken() if (encryptedToken ! null) { return } val oldAccessToken oldPlainTokenStore.getAccessToken() val oldRefreshToken oldPlainTokenStore.getRefreshToken() if (!oldAccessToken.isNullOrBlank() || !oldRefreshToken.isNullOrBlank()) { secureTokenStore.saveToken( accessToken oldAccessToken, refreshToken oldRefreshToken ) oldPlainTokenStore.clear() } }这个迁移逻辑非常重要。它体现的是企业级项目里的兼容意识。十五、退出登录时必须清 Token退出登录不能只是跳登录页。完整流程应该是1. 调后端 logout 接口 2. 后端让 refreshToken 失效 3. 客户端清空本地加密 Token 4. 客户端清空内存 Token 5. 清空用户信息缓存 6. 跳转登录页客户端要注意即使 logout 接口失败本地 Token 也要清掉。因为用户已经主动退出客户端状态必须退出。伪代码suspend fun logout() { runCatching { authApi.logout() } tokenManager.clear() userManager.clear() loginState.emit(false) }十六、日志脱敏也必须一起做Token 加密存储只是第一步。如果日志里继续打印 Token这套安全设计仍然会破。需要检查OkHttp 日志有没有打印 Authorization 本地日志有没有打印 accessToken 本地日志有没有打印 refreshToken Crash 上报有没有带 Token 接口错误日志有没有打印完整 request / response至少要做到Authorization脱敏 accessToken脱敏 refreshToken脱敏 Cookie脱敏 password脱敏 验证码脱敏示例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\:\***\) } }这块和你前面做的企业级日志系统可以打通。日志系统不只是记录问题也要避免把敏感数据带出去。十七、后端也要配合 Token 失效客户端加密存储不是万能的。移动端环境始终不是绝对可信的。如果设备被 Root、Hook、动态调试Token 仍然可能在运行时被拿到。所以真正的安全闭环还需要后端支持accessToken 短有效期 refreshToken 存 Redis refreshToken 轮换 登出让 refreshToken 失效 改密码后全端失效 后台支持踢设备 Token 绑定 deviceId Token 带 jti 用户表加 tokenVersion也就是说客户端负责尽量不泄漏。 后端负责泄漏后能止损。如果没有后端失效机制客户端再怎么加密也只能降低泄漏概率不能解决 Token 泄漏后的控制问题。十八、最终建议的客户端结构比较清晰的 Android 结构可以这样设计TokenManager ↓ SecureTokenStore ↓ CryptoManager ↓ KeystoreKeyManager职责分别是TokenManager 负责登录态业务逻辑比如是否登录、获取 accessToken、清空 Token。 SecureTokenStore 负责 Token 的安全存取对外不暴露明文存储细节。 CryptoManager 负责 AES-GCM 加密和解密。 KeystoreKeyManager 负责从 Android Keystore 中生成和获取 AES key。OkHttp 拦截器只依赖 TokenManagerAuthInterceptor ↓ TokenManager.getAccessToken()拦截器不应该直接操作 DataStore、MMKV、SharedPreferences也不应该直接处理加解密。这样结构才清楚。十九、最终落地清单如果你现在项目里 Token 还是明文存储我建议按这个顺序改第一步封装 TokenManager不让业务代码到处直接读写 Token。 第二步新增 SecureTokenStore统一管理 Token 保存和读取。 第三步新增 CryptoManager用 AES-GCM 加密 Token。 第四步新增 KeystoreKeyManager用 Android Keystore 保护 AES key。 第五步做旧版本明文 Token 迁移。 第六步退出登录时清空本地密文 Token 和内存 Token。 第七步OkHttp / 本地日志 / Crash 上报做 Token 脱敏。 第八步和后端对齐 refreshToken 失效、轮换、踢下线。不要一上来就到处改业务代码。先把 Token 存取收口。只要 TokenManager 收口了后面从 SharedPreferences 换 DataStore从明文存储换加密存储业务层都不需要大改。二十、总结Token 拿到后为什么不能明文存因为 Token 不是普通字符串而是用户登录态凭证。只要 Token 还有效攻击者拿到它就可能绕过账号密码以用户身份访问接口。所以移动端要做到Token 不明文落地 refreshToken 重点保护 AES-GCM 加密 Token Android Keystore 保护 AES key DataStore / MMKV 只保存密文 拦截器运行时解密后加 Authorization Header 日志和崩溃上报必须脱敏 退出登录必须清空 Token 后端必须支持 Token 失效和踢下线一句话总结Token 本地存储的核心不是选 DataStore 还是 MMKV而是先加密再存储DataStore / MMKV 只是柜子AES-GCM 才是锁Android Keystore 才是保管钥匙的地方。