请将身份验证 Token 存储在平台级加密存储中,切勿使用 WebView localStorage 或普通的 SharedPreferences。 在 iOS 上,请使用 Keychain。在 Android 上,请使用 Android Keystore (根据设备类型使用 software、TEE 或 StrongBox)来保护密钥,并使用 Jetpack DataStore + Tink. EncryptedSharedPreferences 已弃用 在 androidx.security.crypto; 请立即规划迁移。对于 Capacitor 应用,应将传输安全 (HTTPS, pinning) 与原生存储插件结合使用,而不是在 JavaScript 中重复实现加密逻辑。
我们如何选择这些存储模式
这里的每一项建议都经过了四重筛选。首先是硬件支持:密钥材料必须存储在 Keychain/Keystore 中,或使用存储在其中的密钥进行加密。其次是官方支持的迁移路径 —— 如果 Google 或 Apple 已弃用该方法,我们不会将其推荐用于新的 code。第三是 Capacitor 兼容性:该模式必须能在插件边界正常工作,且不向 JavaScript 暴露原始密钥。第四是可撤销性:泄露的凭据必须能在服务端失效。
最后一个筛选标准正是本文多次提到 refresh token rotation 的原因。在我们对 Chainalysis 汇总的 2024 年事故数据的回顾中,涉及私钥和凭据的泄露导致了绝大部分加密货币被盗 —— 包括从日本 DMM Bitcoin 被盗的超过 3.05 亿美元,以及从印度 WazirX 被盗的 2.35 亿美元 —— 而共同的失败点在于对单一且持续有效的凭据产生了“密封级”的盲目信任(Chainalysis 2024 犯罪报告)。移动端认证 token 也会以同样的方式失效:以明文形式存储的长效 bearer token 无异于敞开大门。我们不再推荐任何无限期持久化非轮转凭据的架构。
适用于所有平台的原则
下表通过快照进行了简化;其底层逻辑是 access/refresh token 的分离。一个 访问令牌 是短效的——以分钟计,而非天——也是你的 API 所称的“当前令牌”。由于其影响范围小且失效快,它应仅保存在内存中,并在每次应用启动时重建。而 刷新 token 则是用于签发新 access token 的持久化密钥,因此它应当存放在受硬件保护的加密持久化存储中。不同的生命周期决定了不同的存储方式;若将两者以相同方式存储,往往是数据泄露的开始。
运行时完整流程如下:应用启动时没有 access token,使用持久化的 refresh token 调用你的认证服务器,在内存中获取新的 access token,并销毁旧的 refresh token,因为服务器会在每次使用后进行轮换。如果服务器发现已被使用的 refresh token 再次出现,会将其视为被盗证据并撤销整个令牌家族。登出时,客户端应清除本地存储,同时服务器也应撤销授权——仅在本地删除数据而不进行服务器端撤销,那只是在“祈祷”安全,而非真正的安全。
| 实践 | 为什么这很重要 |
|---|---|
| 短效 access token | 降低 token 泄露后的损失 |
| Refresh token 轮换 | 在合法使用后使被盗的 refresh token 失效 |
| 仅限 HTTPS | 阻止常见的网络拦截 |
| 证书固定(有正当理由时) | 降低不当签发或自定义根证书带来的风险;请参阅 OWASP 证书固定指南 |
| 日志、URL 或分析中不得出现 Token | 可观测性工具中的 Token 会变成泄露事件 |
| 登出时撤销 Token | 服务端作废比指望客户端删除数据更可靠 |
@capgo/capacitor-ssl-pinning 当需要更严格的传输控制时,可以在 iOS 和 Android 上为 Capacitor HTTP 固定 TLS。
Android:使用 Keystore 和 DataStore(而非 EncryptedSharedPreferences)
Google 已弃用 androidx.security.crypto 辅助工具,包括 EncryptedSharedPreferences 和 MasterKey。该包摘要建议开发者使用 KeyGenerator 配合 AndroidKeyStore 提供程序 来生成新的密钥材料。
推荐模式
使用端到端的 Tink 驱动 pipeline:
- 使用以下方式构建 Tink keyset
AndroidKeysetManager, storing the master key in Android Keystore via anandroid-keystore://URI(参见datastore-tink发布说明). - 获取
Aead该键集中的原始值。 - 添加
androidx.datastore:datastore-tink(可通过以下方式获取 DataStore 1.3.0-rc01; 稳定的 1.2.x 版本不包含此构件),包装您的Serializer带AeadSerializer,并将其传递给DataStore。当多个存储库共享一个Aead键时,请为每个存储库提供唯一的associatedData,例如从其文件名导出的稳定值。
Google 将其记录为弃用功能的官方替代路径 androidx.security.crypto 辅助函数。请勿混用独立的 JCA SecretKey 带 AeadSerializer; 序列化程序需要一个 Tink Aead 实例。
关于版本控制的说明:由于该 datastore-tink artifact 仅在 1.3.0-rc01 及更高版本中存在,因此仍在使用稳定版 1.2.x 的团队会在依赖树中找不到文档中提到的替代方案。这一版本差距已经持续了几个发布周期,在我们看来,这也是迁移计划停滞不前的主要原因——请务必决定是使用 release candidate 还是继续等待,而不是被动地被动接受版本变化。
值得了解的 Keystore 选项
setIsStrongBoxBacked(true)何时FEATURE_STRONGBOX_KEYSTORE可提供更强的密钥隔离。- 用户身份验证 绑定 (
setUserAuthenticationRequired),适用于仅在通过生物识别或设备凭据验证后才能访问 token 的场景。 - 避免自动备份 除非你理解恢复行为,否则不要将密钥集或密文存放在此类文件中;Google 的 EncryptedSharedPreferences 文档曾警告,备份可能会在没有密钥的情况下恢复密文。
Capacitor 中的原生 Kotlin 或 Java 模块应在插件内部实现此模式;JavaScript 端应只接收不透明句柄或会话状态,而不是原始密钥。
iOS:Keychain 与 Secure Enclave
该 Keychain Keychain 仍然是 iOS 上存储 token 的正确位置。条目由系统加密并保护。请使用作用域尽可能窄的 accessibility 常量,并 访问控制 当你需要 Face ID、Touch ID 或密码验证时。
accessibility 的选择是真正需要做的决定,而不是照搬默认值。请选择 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly 当 token 必须能用于后台任务(如推送触发的刷新、后台获取),但绝不能通过备份迁移到新设备时,请选择 kSecAttrAccessibleWhenUnlockedThisDeviceOnly 用于仅限前台使用 token 的场景,此时更紧的窗口值得这一限制。两种情况下,都要启用 kSecUseDataProtectionKeychain 现代目标设备上的数据保护 Keychain,而不是旧版基于文件的 Keychain。
CryptoKit 在你自行加密 payload 时很有帮助,但对于标准的 OAuth 风格 bearer token,直接将 token 字符串存入具有正确访问权限的 Keychain 通常就足够了。由 Secure Enclave 支持的密钥(使用 CryptoKit 中的 SecureEnclave 生成的 P-256 密钥)仅在密钥本身绝不能离开安全硬件时才真正发挥作用——例如签名操作或设备认证。它们无法为你存储 token 字符串;它们保护的是你自己的 envelope key。如果你不进行 envelope 轮转并管理其生命周期,那么直接使用 Keychain 存储更简单且同样安全。只有当你同时进行密钥轮转并仔细管理 envelope 时,额外的应用层加密才会有所帮助。
Capacitor 的 @capacitor/preferences 写入 UserDefaults,这并不是 不 安全存储。不要将 token 存放在那里。
Capacitor: Capgo 组织内的安全存储方案
Capgo 会 不 发布了一个名为 “secure storage” 的包,其中两个插件涵盖了常见的 Capacitor 需求:
| 插件 | 最佳适用场景 | 原生底层支持 |
|---|---|---|
@capgo/capacitor-native-biometric setData / getSecureData |
小型密钥 (会话 token、API 密钥) | iOS Keychain;基于 Android Keystore 加密的 SharedPreferences |
| @capgo/capacitor-data-storage-sqlite 支持加密选项 | 较大的或结构化的本地数据 | SQLite (可选 SQLCipher) |
该 键值存储指南 对比 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.
For web builds, never persist production tokens in localStorage。请改用后端 Web 会话模型。
服务端与客户端的 Token 生命周期
是的,移动应用应该存储 refresh token —— 否则只能让用户每次启动应用时都重新认证,这比起真正的安全,更像是一种拙劣的安全表演。关键在于 存储位置 和 存储方式:仅持久化存储 refresh token,且必须存储在 Keychain 或 Keystore 支持的存储中,并配合 Token 轮转(rotation)和服务器端撤销机制。
Token 轮转是确保持久化安全的核心。将 refresh token 作为 一次性使用 的凭据进行发放:每次兑换都会返回一个新的 refresh token 并使旧的失效。服务器需保持一个短期的 重用检测窗口 —— 如果一个已被消耗的 token 再次出现,服务器会将该系列 token 视为已泄露,并撤销整个链条,强制所有共享该链条的设备重新登录。建议为并发请求和离线重连设置一个很短的宽限期(几秒到一分钟),以便合法客户端在收到新 token 前能最后一次使用旧 token。完全不设宽限期会导致网络不稳的用户被锁死;而窗口期过长则会扩大攻击面。
Token 的存储位置取决于其有效期:
- 问题 access token 具有短 TTL(分钟级而非天级),并将其 仅保存在内存中 以供会话使用。
- 将 刷新 token 持久化存储在 Keychain 或 Keystore 加密的存储中,并按上述方式进行轮换。
- 在修改密码、登出或设备丢失时, 应在服务端 撤销 refresh token —— 仅由客户端删除本地数据并不等同于撤销。
- 考虑采用轻量级 Token 绑定 策略(如设备密钥证明或 App 实例 ID),以应对特定的威胁模型需求。
当您需要为 Capacitor 应用修复 Token 处理相关的安全漏洞时, Capgo 实时更新 只要符合应用商店政策,即可快速推送符合条件的 Web 层变更。但 Native 存储层的变更仍需重新构建并提交商店审核。
存储方案对比
| 方法 | 安全性 | Capacitor 适用场景 |
|---|---|---|
| JS 内存变量 | 运行期间安全性高,进程被杀后数据丢失 | 仅限会话期间使用的 access token |
localStorage / 首选项 |
低 | 仅限设置,严禁存储密钥 |
| iOS Keychain / Android Keystore | 高 | 通过原生 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没有任何引用的依赖项。
常见问题
在存入 Keychain 之前,我需要对 token 进行加密吗?
如果 Keychain 的访问权限符合你的威胁模型,通常不需要额外的加密层。当法规或密钥轮换策略有要求时,请添加信封加密(envelope encryption)。
Capacitor 插件中的 SQLCipher 能替代 Keystore 吗?
SQLCipher 保护的是数据库文件,但你仍需在 Keystore 或 Keychain 中存储一个强密钥来解密数据库。请将加密密码的处理视为安全存储设计的一部分。
我可以通过混淆(obfuscation)将 token 存储在 Capacitor Preferences 中吗?
混淆不等于加密。Preferences 映射到 UserDefaults 和 SharedPreferences,缺乏硬件级保护。
移动应用应该存储 refresh token 吗?
是的 —— 但必须存储在 Keychain 或由 Keystore 支持的加密存储中,并配合服务端的轮换与撤销机制。如果不存储,则每次启动都需要重新认证,用户为了降低登录门槛往往会要求降低安全要求。绝对不能接受的做法是将 refresh token 存放在 Preferences、localStorage 或任何明文文件中。
在现代应用中,存储 access token 最安全的方式是什么?
完全不要持久化存储它们。在会话期间将短效 access token 保存在内存中,并在每次启动时通过 refresh token 进行重建。仅持久化存储 refresh token —— 使用 iOS Keychain 或基于 Android Keystore 的加密存储 —— 并通过 token 轮换和撤销机制,在设备遭到破坏时将损失降至最低。
继续阅读 Secure Token Storage: Best Practices for Mobile Developers
如果你正在使用 Token 安全存储:移动开发者的最佳实践 以规划安全与合规,并将其与 加密 以了解 Encryption 中的实现细节, 合规性 有关 Compliance 中的实现细节, Capgo 安全扫描器 关于 Capgo Security Scanner 的产品工作流, Capgo 安全性 关于 Capgo Security 的产品工作流,以及 Capgo 信托中心 关于 Capgo Trust Center 的产品工作流。