HarmonyOS 安全存储实战:Token 生命周期、刷新互斥、退出清理和日志脱敏
登录态问题通常不是登录接口本身,而是 Token 生命周期没有设计好:access token 过期后多个请求同时刷新,refresh token 被覆盖,退出登录只清了内存没清安全存储,日志里打印了敏感字段,用户切账号后仍拿到上一个账号的数据。
这篇文章只解决一个工程问题:HarmonyOS 应用如何把 Token 安全存储、过期判断、刷新互斥、退出清理和日志脱敏做成一条可靠链路。

本文会落到四个结果:
一、先区分四类数据:不是所有登录信息都能同样存
登录相关数据要分级。
| access token | 可直接访问接口 | 安全存储,短生命周期 |
| refresh token | 可换取新 token | 更严格保护,退出必清 |
| userId / tenantId | 可关联用户身份 | 日志脱敏,必要时存储 |
| 昵称、头像 | 低敏展示数据 | 可普通缓存,但要随账号清理 |
最常见的问题是把所有登录信息都塞进一个 UserInfo,然后随手写到 Preferences 或日志里。安全存储的第一步是分级。
二、资料与版本边界:本文写应用层凭据治理
本文示例面向 HarmonyOS NEXT / ArkTS 工程,应用层重点放在 Token 模型、持久化边界、刷新互斥、退出清理和日志脱敏。具体加密存储能力可结合 Keychain / Universal Keystore Kit / Preferences 等官方能力与项目安全要求选择。

| 存储层 | Token 读写、清理、过期 | 加密算法内部实现 |
| 请求层 | 过期判断、自动刷新、重试 | 后端鉴权实现 |
| 会话层 | 切账号、退出登录、状态广播 | 复杂 SSO 体系 |
| 日志层 | 脱敏、错误记录、排查 | 安全审计平台 |

三、Token 模型:明确过期时间和来源
Token 不能只保存字符串。
export interface TokenBundle {
accessToken: string;
refreshToken: string;
accessExpireAt: number;
refreshExpireAt: number;
userId: string;
issuedAt: number;
}
export function accessTokenExpired(bundle: TokenBundle, safeWindowMs: number): boolean {
return Date.now() + safeWindowMs >= bundle.accessExpireAt;
}
export function refreshTokenExpired(bundle: TokenBundle): boolean {
return Date.now() >= bundle.refreshExpireAt;
}
这里的 safeWindowMs 很重要。不要等 Token 刚好过期才刷新,可以提前一两分钟刷新,减少临界点失败。
四、安全存储接口:页面不能直接碰 Token
把 Token 读写封装成仓储,不让页面直接访问存储能力。
export interface TokenStore {
save(bundle: TokenBundle): Promise<void>;
load(): Promise<TokenBundle | undefined>;
clear(): Promise<void>;
}
export class SecureTokenRepository {
constructor(private readonly store: TokenStore) {}
async saveToken(bundle: TokenBundle): Promise<void> {
await this.store.save(bundle);
}
async getToken(): Promise<TokenBundle | undefined> {
return this.store.load();
}
async clearToken(): Promise<void> {
await this.store.clear();
}
}
这层的价值:
五、刷新互斥:多个请求不要同时刷新
并发刷新是登录态最常见的坑。多个接口同时发现 access token 过期,如果都去刷新,可能导致 refresh token 被服务端轮换后互相覆盖。
export interface RefreshResult {
success: boolean;
bundle?: TokenBundle;
message: string;
}
export class TokenRefreshGate {
private refreshing?: Promise<RefreshResult>;
async run(refreshAction: () => Promise<RefreshResult>): Promise<RefreshResult> {
if (this.refreshing !== undefined) {
return this.refreshing;
}
this.refreshing = refreshAction();
try {
return await this.refreshing;
} finally {
this.refreshing = undefined;
}
}
}
这段代码保证同一时间只有一个刷新动作。其他请求等待结果,避免把 refresh token 用乱。
六、请求前拦截:先判断,再带 Token
请求层要在发送前拿到可用 Token。
export class AuthHeaderProvider {
constructor(
private readonly repository: SecureTokenRepository,
private readonly refreshGate: TokenRefreshGate
) {}
async buildAuthHeader(refreshAction: () => Promise<RefreshResult>): Promise<Record<string, string>> {
const bundle = await this.repository.getToken();
if (bundle === undefined) {
return {};
}
if (refreshTokenExpired(bundle)) {
await this.repository.clearToken();
return {};
}
if (accessTokenExpired(bundle, 120000)) {
const refreshed = await this.refreshGate.run(refreshAction);
if (!refreshed.success || refreshed.bundle === undefined) {
return {};
}
await this.repository.saveToken(refreshed.bundle);
return { Authorization: `Bearer ${refreshed.bundle.accessToken}` };
}
return { Authorization: `Bearer ${bundle.accessToken}` };
}
}
边界说明:
七、退出登录:清理不止一个 Token
退出登录要清理内存、持久化、请求队列、页面状态和用户缓存。
export interface LogoutCleaner {
clearMemory(): Promise<void>;
clearSecureStore(): Promise<void>;
cancelPendingRequests(): Promise<void>;
clearUserCache(): Promise<void>;
resetNavigation(): Promise<void>;
}
export async function logoutCompletely(cleaner: LogoutCleaner): Promise<void> {
await cleaner.cancelPendingRequests();
await cleaner.clearMemory();
await cleaner.clearSecureStore();
await cleaner.clearUserCache();
await cleaner.resetNavigation();
}
顺序也有意义:先取消未完成请求,再清 Token;否则某个请求可能在退出后继续拿旧 Token 写数据。
八、切账号:比退出登录更容易漏数据
切账号要防止 A 账号缓存污染 B 账号。
export interface AccountSession {
userId: string;
token: TokenBundle;
createdAt: number;
}
export function sameAccount(current: AccountSession | undefined, nextUserId: string): boolean {
return current !== undefined && current.userId === nextUserId;
}
export async function switchAccount(
current: AccountSession | undefined,
next: AccountSession,
cleaner: LogoutCleaner,
repository: SecureTokenRepository
): Promise<void> {
if (!sameAccount(current, next.userId)) {
await logoutCompletely(cleaner);
}
await repository.saveToken(next.token);
}
切账号时要清页面栈、用户缓存、草稿和请求队列。否则最容易出现“头像变了,但列表还是上一个账号的数据”。
九、日志脱敏:排查不能牺牲安全
日志里不要打印完整 Token。
export function maskToken(token: string): string {
if (token.length <= 12) {
return '***';
}
return `${token.slice(0, 6)}***${token.slice(–4)}`;
}
export interface AuthLog {
action: 'login' | 'refresh' | 'logout' | 'expired' | 'switchAccount';
userIdMasked: string;
tokenMasked?: string;
success: boolean;
message: string;
timestamp: number;
}
日志需要能排查,但不能泄露。userId、手机号、邮箱、Token、设备标识都要按安全要求处理。
十、Token 问题排查表
| 多个请求同时 401 | 并发刷新 | 查 refresh 日志 | 加 TokenRefreshGate |
| 退出后仍访问接口 | 未取消请求 | 查 pending request | 退出先取消请求 |
| 切账号数据串了 | 用户缓存未清 | 查 cache key | key 带 userId 或退出清理 |
| 日志有敏感信息 | 未脱敏 | 查 auth 日志 | 使用 maskToken() |
| refresh token 失效仍重试 | 过期判断缺失 | 查过期时间 | 过期后清登录态 |
| 重新打开仍是旧账号 | 安全存储未清 | 查 TokenStore | clear() 必须可靠 |
登录态问题排查不要只看接口返回码,要看 Token 生命周期和请求并发。
十一、上线前安全验收表
| Token 分级清楚 | access/refresh/userInfo 不混存 |
| 存储入口统一 | 页面不直接读写 Token |
| 刷新互斥 | 同一时间只有一个刷新请求 |
| 退出清理完整 | 内存、存储、请求、缓存、页面都清 |
| 切账号不串数据 | 缓存和请求按用户隔离 |
| 日志已脱敏 | 不打印完整 Token 和敏感身份 |
| 过期有兜底 | refresh 过期进入重新登录 |
这张表适合放到登录态改造和发版前自查里。
十二、安全存储相关官方资料
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/universal-keystore-kit-overview
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/keychain-kit-introduction
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-preferences
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/security-overview
十三、把登录态做成可恢复也可清理的系统
安全存储不是单点能力,而是登录态治理的一部分。Token 模型、存储仓储、刷新互斥、退出清理、切账号隔离和日志脱敏缺一不可。
最后用这张表复盘:
| Token 存在哪里 | 统一 SecureTokenRepository |
| 什么时候刷新 | access token 快过期时提前刷新 |
| 多个请求谁刷新 | TokenRefreshGate 互斥 |
| 退出清哪些东西 | 请求、内存、存储、缓存、页面 |
| 日志怎么排查 | 只记录脱敏字段和动作结果 |




