
产品经理提了个需求,听上去很简单:用户在 App 里收藏的文章,打开官网也要能看到。
很多团队的第一反应是:既然是同一个账号,App 里已经有 Token 了,扫个码或者跳个链接,把 Token 带给网页不就行了?
这样做能跑,但等于把同一把钥匙配给了两扇安全等级完全不同的门。本文以开源项目 AppKernia 的实现为例(uni-app x 移动端 + Next.js 官网 + Go 服务端),讲清楚一件事:共用的是用户资料,不是会话凭据。 文中的代码路径和接口都能在公开仓库里核对。
一、先分清“同一个用户”和“同一个会话”
账号是一个人,会话是这个人在某个设备、某个浏览器上的一次登录。一个账号可以同时有多个会话,每个会话都有自己的标识,可以单独撤销。
AppKernia 的做法是:官网不新建 Web 专用的用户表,直接调用 App 已有的 /api/v1/me 读取当前用户。所以手机和浏览器看到的是同一份资料、同一批收藏;但两边各自登录、各自持有会话。
这样设计有三个直接好处:
- 在手机上点“退出其他设备”,可以精确踢掉浏览器上的那次登录,不影响手机自己。
- 浏览器的 Cookie 泄露了,影响的只是那一个会话,不会连带手机。
- 排查问题时,看会话标识就知道请求来自哪个端。
如果确实需要“手机扫码登录网页”这类跨端单点登录,应该另外设计一套授权跳转和回调校验,生成一次性的授权票据,不能把手机上的 Token 放进 URL。URL 会进入浏览历史、服务器访问日志,还可能经过第三方跳转链,这些地方都不该出现凭据。
还有一条容易漏掉的边界:管理后台用的是另一套身份。AppKernia 的后台只能调用 /admin-api/v1,App 和官网只能调用 /api/v1,两类 Token 的 audience 是隔离的。用户在官网登录了,并不代表他拿到了后台的发布权限。
二、三种凭据分别放在哪

示意图:说明存储与认证边界,不代表部署拓扑。
浏览器端:页面脚本永远拿不到 Token。
官网采用同源 BFF(Backend for Frontend):页面只请求网站自己的 /api/session 接口,由 Next.js 服务端代为调用后端 App API。Access Token、Refresh Token 和设备标识都写在 HttpOnly Cookie 里,并做了三重限制:
- Cookie 路径限定在
/api/session,别的接口带不上它; - 生产环境设置
Secure,只走 HTTPS; - 前端拿到的 JSON 里不返回 Token,localStorage 里也不存 Token。
写请求还会额外校验 Origin 和站点来源,业务接口走显式白名单。这里要提醒一句:HttpOnly 只能防止脚本读取 Cookie,防不了跨站请求,也代替不了接口授权。这几道防线各管一件事,少一道都不行。
App 端:短期凭据放内存,长期凭据放系统安全存储。
Access Token 只放在内存里,App 被杀掉就没了。Refresh Token 通过系统安全存储保存:iOS 用 Keychain,Android 用 Keystore,HarmonyOS 用 Asset Store。语言、主题这类非敏感设置可以放普通本地存储,但必须和凭据分开存。
一个实际的坑:HarmonyOS Asset Store 单个值的上限是 900 个 UTF-8 字节,Refresh Token 设计得太长会存不进去。安全存储模块也没有 Web 实现,更没有“存不进去就退回普通存储”的降级逻辑。这是有意为之:降级到明文存储,等于没有安全存储。
三、页面一刷新就“掉登录”?先恢复身份,再判断状态
浏览器刷新以后,页面内存会被清空,但 HttpOnly Cookie 还在。很多前端代码是这么写的:
// 反例:只看内存里的初始值
const user = useStore((s) => s.user) // 刷新后为 null
if (!user) redirect('/login') // 误判为未登录
用户明明是登录状态,却被踢回了登录页。
正确的顺序是:账户组件先进入“加载中”,调用会话接口读取资料,拿到结果后再决定显示账户页还是登录入口。
在 AppKernia 官网中,Access Token 缺失或过期时,服务端会尝试用 Refresh Token 轮换,然后重新读取 /me。这里有两种失败要区分清楚:
| 情况 | 正确处理 |
|---|---|
| 会话确实无效(Refresh Token 已撤销或过期) | 清除 Cookie,回到未登录状态 |
| 后端超时、限流或暂时不可用 | 保留会话,返回可恢复的错误,让用户重试 |
如果把第二种当成第一种处理,网络抖一下,用户就得重新输入密码。
四、多开几个标签页,Refresh Token 就“已被使用”
这是最隐蔽的一个问题。
Refresh Token 一般设计成轮换制:用一次就作废,换回一枚新的。假设用户同时开了三个标签页,Access Token 恰好在同一时刻过期,三个页面会各自发起刷新:

示意图:并发刷新竞态与两层协调。
第一个请求成功,旧 Refresh Token 随即作废;第二、第三个请求拿着同一枚旧 Token 去刷新,服务端看到的就是“一枚已经用过的 Refresh Token 又出现了”。
在 AppKernia 服务端,Refresh Token 只以 SHA-256 哈希的形式保存。一旦检测到重复使用,服务端会撤销整个会话族,并记录一条 high 级别的安全事件 iam.refresh_token.reuse。从安全角度看这是对的:重复使用往往意味着 Token 被盗。但这也意味着,前端如果不做并发协调,用户只是多开了几个标签页,就会被当成被盗号处理,全部掉线。
AppKernia 官网用了两层协调:
- 浏览器层:在支持 Web Locks API 的浏览器里,用锁把会话请求串行化,同一时间只有一个标签页在刷新。
- 服务端层:同一个 Web 进程内,并发到达的刷新请求共享同一次轮换的结果,不会重复消费旧 Token。
但这套方案有明确的上限,必须写进部署文档:进程内协调覆盖不到多个 Web 实例。 官网一旦横向扩容到多个实例,就需要共享的会话协调方案,比如基于 Redis 的分布式锁或单飞(single-flight)机制,并且要重新验证多标签页、Cookie 更新和并发轮换这几个场景。单实例测试通过,不能当成分布式环境的验收结论。
五、刷新成功之后,失败的写请求能不能自动重放?
假设“收藏”请求发出去了,结果响应丢了:网关超时,或者连接断开。客户端这时无法知道数据库里到底存没存。
AppKernia 的规则是:没有幂等保证的写请求,不自动重放。 会话代理会先完成身份检查,然后只发送一次业务写入。
收藏接口本身是幂等的:数据库对“用户 + 文章”建了唯一约束,并使用冲突忽略,重复保存仍然只有一条记录。所以收藏可以安全地让用户手动再点一次。但支付、下单、发消息这类操作,没有幂等键就自动重试,可能造成重复扣款或重复发送。“能不能重试”要按接口逐个设计,不能在网络层统一打开。
六、一份可以直接拿去用的验收清单
| # | 场景 | 期望结果 |
|---|---|---|
| 1 | 两个客户端登录同一测试账号 | 资料一致,会话标识不同 |
| 2 | 在一端修改语言 | 读取服务端的值,确认已保存;保存失败要有提示 |
| 3 | 撤销其中一个会话 | 被撤销的一端无法恢复;另一端按规则仍然有效 |
| 4 | 只保留 Refresh Token 时刷新页面 | 自动恢复登录,不跳转到登录页 |
| 5 | 后端暂时不可用时刷新页面 | 显示可重试的错误,不清除会话 |
| 6 | 3 个标签页同时触发刷新 | 全部保持登录,没有“令牌已使用”错误 |
| 7 | 退出后直接调用受保护接口 | 返回 401 |
| 8 | 请求中伪造另一个 App 上下文 | 被服务端拒绝 |
| 9 | 同一用户重复收藏同一篇文章 | 只有一条记录 |
要注意:截图只能证明页面状态。Cookie 是否轮换、权限是否生效,要去看真实的网络请求和服务端日志。
写在最后
“同一个账号”是业务概念,“同一个会话”是安全概念。把两者拆开,代价只是多写一个 BFF 和一套会话管理;把两者混在一起,代价会在某次 Token 泄露或者某次多标签页刷新时一次性付清。
文中的实现都在 AppKernia 开源仓库里,MIT 许可,可以商用:
- 源码:github.com/Payhon/AppKernia
- 文档:docs.appkernia.com
如果你正在做一个 App + 后台 + 官网的产品,需要现成的商城支付、会员点数、AI 客服和 Next.js 官网,可以了解一下 AppKernia Official 版(官网首页“版本对比”)。