H5 在小程序 web-view 里登录态总是丢:公众号网页授权与 token 互通的踩坑笔记
适用读者:在小程序 web-view 里承载业务 H5,同时还要兼顾公众号菜单入口和独立浏览器入口的前端与后端同学。文中涉及的接口名、参数名按微信官方文档原样保留,代码跑在 Node 18 + Express 与现代浏览器环境。
上线第一周,客服群里攒了 47 条同一个问题的反馈:同一批用户,从公众号菜单点进去一切正常,从小程序首页 banner 点进去就掉登录态,刷新一下又好了。我们自己的埋点更直接——公众号入口的 H5 首屏接口 401 率是 0.3%,小程序 web-view 入口是 18.7%,独立浏览器是 0.1%。
前端同学第一反应是 cookie 没带上去,后端同学怀疑是网关把 header 吃了,两边各查了两天,最后发现谁都没写错,是三个入口压根就不在同一个会话语境里。
三个入口,三种完全不同的语境
先把最容易混淆的一件事说清楚:同一个 H5 域名、同一份前端代码,不代表同一份登录态。用户从哪儿进来,决定了它能拿到什么身份、能用什么存储。

| 入口场景 | 能否走公众号网页授权(OAuth2 web authorization) | 拿到的身份 | 存储可用性 | 典型现象 |
|---|---|---|---|---|
| 公众号菜单 / 模板消息点开的 H5 | 能,微信客户端顶层浏览器,授权跳转链完整 | 公众号 openid,绑定开放平台后带 unionid | cookie / localStorage 都在微信 WebView 分区里,稳定 | 登录态能维持数天 |
| 小程序 web-view 里的 H5 | 授权跳转链会断,open.weixin.qq.com 不在业务域名白名单里 | 拿不到公众号 openid,只有小程序侧传进来的身份 | 与微信浏览器不是同一分区,iOS 上尤其容易被回收 | 首屏 401、刷新时好时坏 |
| 独立浏览器直接访问 | 根本走不通,授权域名会提示在非微信客户端打开 | 无微信身份,只能靠手机号 / 账号密码 | 完全正常 | 表现为"未登录",符合预期 |
这张表是我们排查的分水岭。以前写授权逻辑时默认"用户一定在微信里、一定能跳回 redirect_uri",这个假设只在第一行成立。
公众号入口:链路完整但别踩 state
公众号菜单进来时,整条链路是闭合的:H5 判断无登录态 → 302 到授权地址 → 微信带 code 回跳 → 后端用 code 换网页授权 access_token(web authorization access token)→ 拿 openid / unionid → 下发自建 token。
这路子最容易翻车的是 state 参数。我见过把 state 当业务参数用的写法,塞了一串带 & 的原始 URL,回跳时后端按 & 切开,state 直接被截成半截,然后整条链静默失败。state 必须先 urlencode 再拼,或者干脆只放一个随机串 + 后端 session 记录来源。
小程序入口:拿不到 openid 才是正常的
小程序里 web-view 组件的 src 只能加载小程序后台配置的业务域名(business domain),而授权链路的第一跳是 open.weixin.qq.com,这个域名你没法配进业务域名,配了也会被校验文件拦。于是 H5 里那句"没登录就跳授权"在 web-view 里要么白屏,要么跳一半卡死,用户看到的就是"登录态丢了"。
结论:小程序 web-view 里的 H5 不能自己发起公众号网页授权,身份必须由小程序宿主传进来。
独立浏览器:别硬撑
独立浏览器里走授权是走不通的,别为了"统一"给这条路径也套授权跳转。我们最后给独立浏览器的方案很土:落地页直接展示精简版内容 + 手机号登录入口,不做任何微信身份尝试。
先把 openid 和 unionid 的关系理顺
身份这块不理清,后面任何"互通"都是空的。同一用户在不同应用里的 openid 是不同的,公众号的 openid 和小程序的 openid 不相等,这不是 bug,是设计。
| 字段 | 生成主体 | 跨应用是否一致 | 获取条件 | 我们怎么用 |
|---|---|---|---|---|
| 公众号 openid | 该公众号下的用户标识 | 否,换个公众号就变 | snsapi_base 静默授权即可拿到 | 只做公众号侧自己的用户主键 |
| 小程序 openid | 该小程序下的用户标识 | 否 | wx.login 拿 code 换 code2session | 只做小程序侧主键 |
| unionid | 微信开放平台(open platform)账号 | 是,同一开放平台下的应用一致 | 公众号 / 小程序均需绑定到同一开放平台账号 | 做全局用户主键,跨端打通 |
关键点:unionid 不是白给的,必须把公众号和小程序绑到同一个微信开放平台账号下,否则接口返回的 JSON 里压根没有 unionid 这个字段,而且不报错。我们排查时在这里卡了半天——返回 200、openid 正常、就是没 unionid,最后是去开放平台后台发现小程序绑在了另一个账号上。
另外一个容易忽略的点:小程序端 code2session 能否拿到 unionid,还跟用户是否关注过同主体公众号、是否授权过有关。所以不要假设小程序侧一定拿得到 unionid,拿不到时要有降级路径。
原理剖析:授权重定向链到底走了几跳
这一节把机制讲透,后面所有坑都能从这里推出来。
微信公众号网页授权本质是 OAuth2 的授权码模式(authorization code grant),只是微信把 authorize 和 token 两个端点都放在自己的域名下,并且强制要求整条链必须在微信客户端里完成。一次 snsapi_userinfo 授权,实际发生的跳转是这样的:
sequenceDiagram
participant U as 用户
participant H5 as 业务 H5 页面
participant WX as 微信授权服务器
participant B as 业务后端
U->>H5: 从公众号菜单进入 / 页面发现无 token
H5->>WX: 302 到 authorize,带 appid、redirect_uri、scope、state
WX->>U: snsapi_userinfo 时弹出授权确认页
U->>WX: 用户点同意
WX->>H5: 302 回 redirect_uri,带 code 与 state
H5->>B: 把 code 交给后端
B->>WX: code 换网页授权 access_token 与 openid
WX-->>B: 返回 openid、access_token、refresh_token
B->>B: 用 unionid 定位或创建用户,签发自建 token
B-->>H5: 回写 token(Set-Cookie 或 302 带回)
链路里有三条硬约束,每一条都对应一个真实的坑:
- code 只有 5 分钟有效期,且只能用一次。前端重试、后端重试、网关重试,任何一次重复用同一个 code 换 token 都会返回 40029(invalid code)。我们线上出现过代码里 axios 超时重试导致 code 被二次消费的故障。
- redirect_uri 必须做 urlencode,且域名必须与公众号后台配置的网页授权域名完全一致,差一个
www都不行。 - 网页授权 access_token 和调用接口用的全局 access_token(stable access token)是两个东西,前者跟用户绑定、有效期 2 小时,后者跟公众号绑定、有效期 7200 秒。两者不能混用,混用的报错是 40001,很容易误导排查方向。
scope 的选择也值得单独说:snsapi_base 是静默的,用户无感知,只拿 openid;snsapi_userinfo 会弹授权页,能拿昵称头像,但有用户拒绝的成本。我们的做法是首屏一律 snsapi_base 静默拿身份,只在用户主动点"完善资料"时才升到 snsapi_userinfo,转化率上差了大概 6 个百分点。
web-view 里 localStorage 为什么时灵时不灵
存储这块是我们踩得最懵的一段,因为它的表现是随机性的:同一台 iPhone,上午进去有登录态,下午进去没有。
flowchart LR
A[微信小程序宿主] --> B[web-view 组件]
B --> C[iOS WKWebView / Android XWeb 内核]
C --> D[H5 页面 localStorage 分区]
D --> E[被系统回收或按域名策略清理]
A --> F[小程序 Storage,与 D 不通]
D -.不共享.-> F
原因拆开看主要有三层:
第一层,web-view 与微信浏览器不是同一个存储分区。 小程序 web-view 在 iOS 上跑在 WKWebView 里,Android 上跑在微信自研内核里,这两个实例的 localStorage 与你在微信内置浏览器里打开同一域名时看到的,通常不是同一份数据。用户在公众号里登录过,不代表小程序 web-view 里能读到。
第二层,iOS 的回收策略很激进。 WKWebView 的数据存储在系统管控的目录下,内存紧张或者小程序被后台清理时,这块数据可能被整体回收。所以"重启小程序就没了"是正常现象,不是你代码写错了。
第三层,cookie 也一样不牢靠。 依赖 Set-Cookie 维持会话的方案,在 web-view 里同样会被清。而且一旦 cookie 被清但页面还有缓存的旧 token 副本,就会出现"前端以为登录了、后端说 401"的错位。
所以:web-view 里的 H5 不要把登录态当持久化数据用。 我们的处理是 token 只放内存变量 + 单次会话内可靠,持久化身份放在小程序宿主侧(wx.setStorageSync),H5 每次进来现取。
配置坑:业务域名和那个校验文件
小程序 web-view 能不能加载你的 H5,取决于业务域名(business domain)配置,这一节单独列出来,因为它的报错信息基本没有提示性——表现就是白屏或者 不支持打开非业务域名。
踩过的几条:
- 域名必须备案,必须 HTTPS,端口必须是 443,
src里带:8443这种非标准端口直接失败。 - 后台添加业务域名时会给一个校验文件,要放到域名根目录能直接访问,放
/static/下是校验不过的。我们有次是 CDN 回源规则把.txt拦了,校验一直失败。 - 一个小程序最多配 200 个业务域名,一年只能改 50 次,别拿生产小程序做试验。
- web-view 指向的 URL 里带中文或特殊字符要先 encodeURIComponent,否则 iOS 上白屏、Android 上却能打开,这种平台差异最费时间。
最终方案:统一签发自建 token,URL 只传一次性 ticket
到这里方案其实已经收敛了。核心思路是:微信的 code 只在宿主侧消费一次,之后所有端都认我们自己签的 token;web-view 的 URL 上不放 token,只放一个 60 秒有效、单次消费的 ticket。
// 运行环境:小程序基础库 2.x +,web-view 组件
// 宿主页面负责拿 code、换 ticket,再拼出 web-view 的 src
Page({
data: { webUrl: '' },
async onLoad() {
// wx.login 拿到的 code 只能换一次,别缓存复用
const { code } = await wx.login()
// 后端用 code 调 code2session,拿 unionid 定位用户,返回一次性 ticket
const { data } = await wx.request({
url: 'https://api.example.com/mini/ticket',
method: 'POST',
data: { code }
})
// ticket 有效期 60 秒,消费一次即失效,不直接把 token 放在 URL 上
const src = `https://h5.example.com/home?ticket=${encodeURIComponent(data.ticket)}`
// 加时间戳避免 web-view 复用旧 src 命中缓存
this.setData({ webUrl: `${src}&_t=${Date.now()}` })
// 若需要把登录态留给下次,另外写 wx.setStorageSync,别指望 H5 自己存住
}
})
后端这边,ticket 换 token 的接口要满足"单次消费 + 短时效 + 绑定用户",我们用一个内存里的 ticket 表(生产换 Redis,带 TTL)来做。
// 运行环境:Node 18 + Express 4 + jsonwebtoken 9
// 依赖:npm i express jsonwebtoken axios
const express = require('express')
const jwt = require('jsonwebtoken')
const axios = require('axios')
const app = express()
app.use(express.json())
// ticket 存储:生产环境请用 Redis 并设置 TTL,这里用 Map 演示
const ticketStore = new Map()
// 有效期 60 秒,避免 URL 被分享后长期可用
const TICKET_TTL = 60 * 1000
// 定时清理过期 ticket,防止 Map 无上限增长
setInterval(() => {
// 遍历所有记录,过期的直接删掉
for (const [k, v] of ticketStore) {
// 过期判断与下面的消费判断保持一致
if (v.expire < Date.now()) ticketStore.delete(k)
}
// 清理间隔 60 秒,与 ticket 有效期对齐
}, 60 * 1000).unref()
// 步骤一:小程序侧用 wx.login 的 code 换 ticket
app.post('/mini/ticket', async (req, res) => {
const { code } = req.body
// code2session 是小程序登录凭证校验接口,code 5 分钟有效且只能用一次
const r = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
params: {
appid: process.env.MINI_APPID,
secret: process.env.MINI_SECRET,
js_code: code,
grant_type: 'authorization_code'
}
})
// unionid 只有绑定开放平台后才会返回,拿不到时要能降级
const { openid, unionid } = r.data
// 这里一定要判空:code 被重复消费时微信只返回 errcode,不会抛异常
if (!openid) return res.status(401).json({ msg: 'code 无效或已使用' })
// 用 unionid 优先定位全局用户,没有则回落到小程序 openid
const user = await findOrCreateUser({ unionid, miniOpenid: openid })
// ticket 只存随机串,不存用户信息,消费时再查
const ticket = require('crypto').randomBytes(16).toString('hex')
ticketStore.set(ticket, { uid: user.id, expire: Date.now() + TICKET_TTL })
res.json({ ticket })
})
// 步骤二:H5 用 URL 上的 ticket 换正式 token
app.post('/h5/exchange', (req, res) => {
const { ticket } = req.body
const rec = ticketStore.get(ticket)
// 取完立刻删,保证一次 ticket 只能换一次 token
if (!rec || rec.expire < Date.now()) {
ticketStore.delete(ticket)
return res.status(401).json({ msg: 'ticket 已失效' })
}
ticketStore.delete(ticket)
// 自建 token 有效期 7 天,签名密钥放环境变量
const token = jwt.sign({ uid: rec.uid }, process.env.JWT_SECRET, { expiresIn: '7d' })
// 前端拿到后放内存,由前端自己决定是否写 sessionStorage
res.json({ token })
})
app.listen(3000)
前端 H5 侧的对接就很薄了:优先从 URL 读 ticket 换 token,换完立刻用 history.replaceState 把 ticket 从地址栏抹掉,token 放内存变量,每次请求用 Authorization 头带上。
// 运行环境:现代浏览器,fetch 原生支持
// 这段代码在页面最早的位置执行,避免业务接口先发出去造成 401
let token = sessionStorage.getItem('token') || ''
// 从 URL 上取一次性 ticket,取到就立刻换 token
const ticket = new URLSearchParams(location.search).get('ticket')
if (ticket) {
const r = await fetch('https://api.example.com/h5/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket })
})
const j = await r.json()
token = j.token
// 换完马上把 ticket 从地址栏抹掉,防止被分享或记录在浏览器历史里
history.replaceState(null, '', location.pathname)
// 只写 sessionStorage 做同标签页续期,web-view 里的 localStorage 不可靠
sessionStorage.setItem('token', token)
}
// 后续请求统一带 Authorization 头,不再依赖 cookie
async function request(url, options = {}) {
// 若返回 401 说明 token 过期,需要重新走一次 ticket 换 token
return fetch(url, {
...options,
// Authorization 头不受 web-view 存储回收影响,内存里的 token 还在就可用
headers: { ...options.headers, Authorization: `Bearer ${token}` }
})
}
关于"token 能不能放 URL 参数"这个问题,我们的态度是:正式 token 不放,一次性 ticket 可以放,但必须短时效 + 单次消费 + 用完即删。 URL 上的东西会通过 Referer 头泄漏给第三方资源、会进浏览器历史、会被网关访问日志记录,放一个 7 天有效的 token 上去等于把钥匙贴在门上。
上线之后观察到的几个变化
改完这套之后跑了四周,几个数字:web-view 入口首屏 401 率从 18.7% 降到 0.4%,与公众号入口基本持平;客服那边"登录态丢失"类工单从每周 40 多条降到个位数;用户平均停留时长涨了约 20%。
还有个意外收获:因为 token 统一由后端签发,小程序原生页面和 H5 现在能共享同一套鉴权中间件,以前两套并存的权限判断逻辑删掉了三百多行。
误区澄清,以及往后的一点判断
有几个流传挺广的说法想掰一下。一是"web-view 里配一下 cookie domain 就能共亨登录态",这是不成立的,分区和回收策略都不在你控制范围内。二是"公众号的 openid 直接存库当主键就行",只要哪天接了小程序或开放平台,这个主键就会裂开。三是"snsapi_userinfo 信息多,干脆全用它",代价是每次都有授权弹窗打断,静默授权能拿到的使用率明显更高。
往后看,微信侧对跨端身份的表达基本稳定在 openid + unionid 这套模型上,变化大概率发生在宿主能力上——比如小程序与 H5 之间的通信方式会更丰富,但"H5 自己拿不到微信身份"这件事短期内不会变。所以把身份收敛到宿主侧、把鉴权收敛到自建 token 这一层,是个不容易过时的结构。
你们在 web-view 里还遇到过哪些更刁钻的存储问题,或者对 ticket 换成 postMessage 传递有什么想法,评论区聊聊。
参考与延伸
- 微信网页授权官方文档:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webview_authorization.html
- 小程序 web-view 组件说明:https://developers.weixin.qq.com/miniprogram/dev/component/web-view.html
- 获取 access_token 接口文档:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
- 小程序登录 code2Session 文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
微信公众号开发, 微信小程序, web-view, 网页授权 OAuth2, openid 与 unionid, 登录态互通, 自建 token