在现代应用中,存储访问令牌最安全的方式是不将其持久化:在会话期间将短生命周期的访问令牌保留在内存中,并在每次启动时从存储在平台支持的加密存储中的刷新令牌生成新的访问令牌。在 iOS 上,仅将刷新令牌持久化到 Keychain 在 Android 上使用 Android Keystore (根据设备不同,可为软件、TEE 或 StrongBox)以及 Jetpack DataStore + Tink. EncryptedSharedPreferences 已弃用 中的 androidx.security.crypto;请尽早规划迁移。对于 Capacitor 应用,请结合传输安全(HTTPS、 SSL 固定工具和插件),包含 原生支持的存储插件 以及 @capgo/capacitor-native-biometric 而不是在 JavaScript 中重复实现加密逻辑。
我们如何选择这些存储模式
这里的每项建议都经过四道筛选。第一,受平台保护的存储:机密信息必须存放在 Keychain 或 Keystore 中,或使用存放在其中的密钥加密。如果设备和密钥配置确实支持,请优先选择硬件级隔离(例如基于 StrongBox 或 Secure Enclave 的密钥)。第二,官方支持的迁移路径。如果 Google 或 Apple 已弃用该方案,我们不建议在新的 code 中使用。第三,Capacitor 兼容性:该模式必须能在插件边界处正常工作,且不向 JavaScript 暴露原始密钥。第四,可撤销性:泄露的凭据必须能在服务端被作废。
最后一道筛选正是刷新令牌轮换贯穿全文的原因。Chainalysis 报告称,2024 年日本 DMM Bitcoin 因私钥泄露被盗超过 3.05 亿美元(Chainalysis 2024 犯罪报告)。移动端认证令牌也会以同样方式出问题:明文存储的长期 Bearer 令牌无异于敞开大门。我们会停止推荐任何无限期保存不轮换凭据的架构。
适用于所有平台的原则
下表以快照形式概括;底层逻辑是访问令牌/刷新令牌分离。一个 访问令牌 是短期的,以分钟计而非以天计,也是你的 API 称之为 present 的东西。由于影响范围小、失效快,它只应放在内存中,并在每次应用启动时重建。 刷新 token 是持久密钥,用于签发新的访问令牌,因此只有它才应该存入由硬件支持的持久化加密存储中。生命周期不同,存放位置也应不同;把两者以相同方式存储,正是大多数泄露的起点。
运行时的完整流程如下:应用启动时没有访问令牌,使用持久化的刷新令牌调用你的认证服务器,在内存中收到新的访问令牌,并销毁旧的刷新令牌,因为服务器会在每次使用时轮换它。如果服务器再次看到已使用过的刷新令牌被提交,会将其视为被盗证据并撤销整个令牌族。登出时,客户端清除本地存储,服务器撤销授权。只在本地删除数据而不在服务器端撤销,只是碰运气,不是安全。
| 实践 | 为什么这很重要 |
|---|---|
| 短期访问令牌 | 限制令牌泄露造成的损害 |
| 刷新令牌轮换 | 合法使用后使被盗的刷新令牌失效 |
| 仅 HTTPS | 阻止一般的网络拦截 |
| 证书固定(在合理情况下) | 降低因错误签发或自定义根证书带来的风险;请参阅 OWASP 证书固定指南 |
| 日志、URL 或分析数据中不包含令牌 | 可观测性工具中的令牌会导致数据泄露 |
| 登出时吊销令牌 | 服务端失效优于指望客户端删除数据 |
@capgo/capacitor-ssl-pinning 当需要更严格的传输控制时,可以在 iOS 和 Android 上为 Capacitor HTTP 固定 TLS。有关传输加固的更广泛视角,请参阅 SSL 固定实现工具和插件.
Android:Keystore 与 DataStore(而非 EncryptedSharedPreferences)
Google 已弃用 androidx.security.crypto 辅助函数,包括 EncryptedSharedPreferences 以及 MasterKey。包摘要建议开发者使用 KeyGenerator 配合 AndroidKeyStore 提供者 用于新的密钥材料。
推荐模式
端到端使用基于 Tink 的单一 pipeline:
- 使用
AndroidKeysetManager构建 Tink 密钥集,通过android-keystore://URI 将主密钥存储在 Android Keystore 中(参见datastore-tink发布说明)。在 manager 上调用isUsingKeystore(),并在其返回时采用 fail closed 策略false。否则,Tink 会以明文形式存储密钥集。 - 获取一个
Aead来自该密钥集的原语。 - 添加
androidx.datastore:datastore-tink(DataStore 1.3.0 包含该 artifact;1.2.x 不包含),请封装你的Serializer配合AeadSerializer,并将其传递给DataStore。当多个 store 共享同一个Aead密钥时,请为每个 store 指定唯一的associatedData,例如从其文件名派生的稳定值。
Google 在文档中将其列为已弃用项的受支持替代方案 androidx.security.crypto 辅助函数。不要将独立的 JCA SecretKey 配合 AeadSerializer; 序列化器需要一个 Tink Aead 实例。
值得了解的 Keystore 选项
setIsStrongBoxBacked(true)何时FEATURE_STRONGBOX_KEYSTORE可用于实现更强的密钥隔离。- 用户身份验证 绑定(
setUserAuthenticationRequired),适用于仅在通过生物识别或设备凭据验证后才能访问 token 的情况。 - 避免使用 Auto Backup 用于存储密钥集或密文的文件,除非你了解恢复行为;Google 的 EncryptedSharedPreferences 文档曾警告过,备份可能会在没有密钥的情况下恢复密文。
在 Capacitor 中,原生 Kotlin 或 Java 模块应在插件内部实现此模式;JavaScript 端应仅接收不透明句柄或会话状态,而非原始密钥。
iOS:Keychain 与 Secure Enclave
本指南中介绍的 Keychain 在 iOS 上,Keychain 仍是存储 token 的正确位置。其中的条目经过加密并由系统保护。请使用范围受限的 accessibility 常量,并在 访问控制 需要 Face ID、Touch ID 或密码验证时使用。
accessibility 的选择是一个实际决策,而非默认值。当 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly token 需要被后台任务(如推送触发的刷新或后台获取)使用,但绝不能通过备份迁移到新设备时,请选择 kSecAttrAccessibleWhenUnlockedThisDeviceOnly ;当 token 仅在前台使用时,更严格的限制窗口值得这种约束。在这两种情况下,请在现代目标上启用 kSecUseDataProtectionKeychain ,以获取数据保护 Keychain,而非基于文件的旧版 Keychain。
CryptoKit 在你自行加密 payload 时很有帮助,但对于标准的 OAuth 风格 bearer token,通常只要把 token 字符串直接存入 Keychain 并设置正确的可访问性就足够了。由 Secure Enclave 支持的密钥(使用 SecureEnclave (如 CryptoKit)只有在密钥本身绝不能离开安全硬件时才有意义,例如签名操作或设备认证。它们无法替你保存 token 字符串,而是保护你自己的信封密钥。如果你没有进行信封轮换并管理其生命周期,直接使用 Keychain 存储更简单,安全性也不差。额外的应用层加密只有在同时轮换密钥并妥善管理信封时才有用。
Capacitor 的 @capacitor/preferences 写入 UserDefaults,而它并不是 不会 安全存储。不要将 token 存放在那里。
Capacitor:Cap-go 组织中的安全存储方案
Capgo 不会 不会 发布一个名为“secure storage”的包,但两个插件就能满足常见的 Capacitor 需求:
| 插件 | 最适合 | 原生底层支持 |
|---|---|---|
@capgo/capacitor-native-biometric setData / getSecureData |
小型密钥(会话 token、API 密钥) | iOS Keychain;基于 Android Keystore 的 SharedPreferences 加密 |
| @capgo/capacitor-data-storage-sqlite 提供加密选项 | 较大的或结构化的本地数据 | 支持可选 SQLCipher 的 SQLite |
本指南中介绍的 键值存储指南 对比 Preferences、SQLite 存储以及 NativeBiometric.setData 用于存储密钥。
需要 @capgo/capacitor-native-biometric 8.6.0 或更高版本 (请确保插件主版本与您的 Capacitor 主版本一致)。生物识别读取示例:
import {
AccessControl,
NativeBiometric,
} from '@capgo/capacitor-native-biometric';
const TOKEN_KEY = 'auth.refresh_token';
export async function saveRefreshToken(token: string) {
await NativeBiometric.setData({
key: TOKEN_KEY,
value: token,
accessControl: AccessControl.BIOMETRY_CURRENT_SET,
});
}
export async function loadRefreshToken(): Promise<string | null> {
try {
const { value } = await NativeBiometric.getSecureData({
key: TOKEN_KEY,
reason: 'Unlock your session',
title: 'Authenticate',
});
return value ?? null;
} catch {
return null;
}
}
getData 仅返回已存储的值 无需 accessControl。若需生物识别保护,请务必配合使用 BIOMETRY_CURRENT_SET 或 BIOMETRY_ANY 配合 getSecureData.
对于 Web 构建,切勿在生产环境中持久化存储 token localStorage. 请改用后端的 Web 会话模型。
服务端和客户端的 Token 生命周期
是的,移动应用应该存储刷新令牌。否则,用户每次启动应用时都得重新认证,这比真正的安全更糟糕,只是安全表演。关键在于 其中 以及 怎么做:只持久化 refresh token,只存到 Keychain 或 Keystore 支持的存储中,并且背后要有轮转和服务端撤销。
如何在移动应用中安全地管理和轮换刷新令牌
轮转才能让持久化变得安全。把 refresh token 作为 一次性 凭据签发:每次兑换都会返回新的 refresh token,并使旧的失效。服务器会维护一个很短的 重用检测窗口。如果一个已标记为已使用的令牌再次被提交,系统会认为该令牌家族已泄露,并撤销整个令牌链,强制所有共享该令牌的设备重新登录。允许一个短暂的宽限期(几秒到一分钟),以应对竞态请求和离线重连,此时合法客户端可能在得知替换令牌前最后一次提交旧令牌。完全没有宽限期会在不稳定的连接上锁定用户;窗口过长则会扩大攻击面。
每个 token 的存放位置直接取决于其有效期:
- 问题 access token 具有短 TTL(分钟级而非天级),并将它们 仅保存在内存中 供会话期间使用。
- 将 刷新 token 持久化存储在由 Keychain 或 Keystore 支持的加密存储中,并按上述方式轮换。
- 在修改密码、登出或设备丢失时, 撤销 在服务器端刷新 refresh tokens。客户端删除本地数据并不等于撤销。
- 考虑使用轻量级的 Token 绑定 策略(设备密钥证明或 App 实例 ID),前提是你的威胁模型有此需求。
当你为 Capacitor 应用发布涉及 Token 处理的安全修复时, Capgo 实时更新 只要遵守应用商店政策,就能快速交付符合条件的 Web 层变更。原生存储变更仍需通过应用商店构建。Capgo 的 安全概览 总结了生产应用如何结合传输、存储和更新控制。
存储方案对比
| 方法 | 安全 | Capacitor 适用性 |
|---|---|---|
| 内存中的 JS 变量 | 运行时安全性高,进程终止后丢失 | 仅限会话期间使用 access token |
localStorage / 偏好设置 |
低 | 仅限设置,绝不存密钥 |
| iOS 钥匙串 / Android 密钥库 | 高 | 通过原生 code 或 Capgo 插件 |
| DataStore + Tink (Android 原生) | 高 | 自定义 Capacitor 插件或原生 auth SDK |
| JS 中自定义加密 | 变量 | 避免使用;JS 中的密钥可被提取 |
最后一行值得注意:JavaScript 加密的失败模式不是算法弱,而是密钥的存放位置。JS runtime 能读取的任何密钥,都可能被 runtime 中的其他代码窃取,因此无论选哪个库,从该层在结构上都无法实现“高安全性”。
从 EncryptedSharedPreferences 迁移的检查清单
- 盘点哪些密钥存储的是敏感信息,哪些是公开配置。
- 实现 DataStore +
AeadSerializer配合由 Keystore 支持的 Tink keyset。 - 使用以下方式迁移:
SharedPreferencesMigration使用一个能打开旧版 EncryptedSharedPreferences 实例的 producer(而非明文SharedPreferences),成功读取后再删除旧文件。 - 通过应用商店发布原生变更;在真机上测试备份与恢复。
- 移除
androidx.security.crypto没有任何引用时的依赖项。
常见问题
如何实现安全的令牌存储?
在 iOS 上,将刷新令牌写入 Keychain,并设置与后台刷新需求相匹配的可访问性。在 Android 上,使用 Android Keystore 中的 Tink 密钥集,配合 Jetpack DataStore 以及 AeadSerializer ,而不是已弃用的 EncryptedSharedPreferences。访问令牌仅保存在内存中,并使用 HTTPS。当威胁模型要求时,添加 证书固定 。
如何在移动应用中安全地管理和轮换刷新令牌?
仅将刷新令牌存储在由 Keychain 或 Keystore 支持的存储中。颁发一次性刷新令牌,使每次兑换都返回新的刷新令牌并使旧令牌失效。保持较短的重用检测窗口,以便重放的令牌会撤销整个令牌族,并为离线或竞争客户端提供一小段宽限期。
在 iOS 和 Android 上,安全存储 access token 和 refresh token 的方式一样吗?
两个平台的策略一致:短期 access token 存内存,refresh token 存平台保护的加密存储。API 不同(Keychain 对比 Keystore 加 DataStore),但两者都必须避免将敏感信息存入 WebView localStorage、UserDefaults 或明文 SharedPreferences。
把 Token 存进 Keychain 前需要加密吗?
如果 Keychain 的可访问性符合你的威胁模型,标准 bearer token 通常不需要额外加密层。当法规或密钥轮换策略有要求时,再添加信封加密(envelope encryption)。
Capacitor 插件中的 SQLCipher 能替代 Keystore 吗?
SQLCipher 保护数据库文件,但你仍需要 Keystore 或 Keychain 中的强密钥来解密数据库。请把加密口令的处理纳入安全存储设计。
能把 Token 混淆后存进 Capacitor Preferences 吗?
混淆不是加密。Preferences 映射到 UserDefaults 和 SharedPreferences,没有硬件级保护。
移动 App 应该存储 Refresh Token 吗?
可以,但仅限基于 Keychain 或 Keystore 的加密存储,并配合服务端轮换和吊销机制。不存储意味着每次启动都要重新认证,用户会因此要求降低登录安全要求。绝对不可接受的是将 refresh token 存入 Preferences、localStorage 或任何明文文件。
现代 App 里存 Access Token 最安全的方式是什么?
根本不要持久化它们。会话期间把短时效 access token 留在内存中,每次启动时用 refresh token 重新生成。只把 refresh token 持久化到 iOS Keychain 或由 Android Keystore 支持的加密存储中,并依靠轮换和撤销,在设备一旦被入侵时限制损害范围。
继续阅读《安全 Token 存储:移动开发者最佳实践》
如果你正在使用 安全 Token 存储:移动开发者最佳实践 来规划安全与合规,请结合 加密 了解 Encryption 的实现细节, 合规性 了解合规部分的实现细节, Capgo 安全扫描器 了解 Capgo Security Scanner 的产品工作流, Capgo 安全 用于 Capgo 安全的产品工作流,以及 Capgo 信任中心 用于 Capgo 信任中心的产品工作流。