H5 在小程序 web-view 里登录态总是丢:公众号网页授权与 token 互通的踩坑笔记

2026-09-20 01:19:18 6 次浏览
微信公众号微信小程序web-viewOAuth2登录态网页授权

适用读者:在小程序 web-view 里承载业务 H5,同时还要兼顾公众号菜单入口和独立浏览器入口的前端与后端同学。文中涉及的接口名、参数名按微信官方文档原样保留,代码跑在 Node 18 + Express 与现代浏览器环境。

上线第一周,客服群里攒了 47 条同一个问题的反馈:同一批用户,从公众号菜单点进去一切正常,从小程序首页 banner 点进去就掉登录态,刷新一下又好了。我们自己的埋点更直接——公众号入口的 H5 首屏接口 401 率是 0.3%,小程序 web-view 入口是 18.7%,独立浏览器是 0.1%。

前端同学第一反应是 cookie 没带上去,后端同学怀疑是网关把 header 吃了,两边各查了两天,最后发现谁都没写错,是三个入口压根就不在同一个会话语境里。

三个入口,三种完全不同的语境

先把最容易混淆的一件事说清楚:同一个 H5 域名、同一份前端代码,不代表同一份登录态。用户从哪儿进来,决定了它能拿到什么身份、能用什么存储。

web-view 登录态互通主题图:令牌在窗口之间传递

入口场景 能否走公众号网页授权(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 带回)

链路里有三条硬约束,每一条都对应一个真实的坑:

  1. code 只有 5 分钟有效期,且只能用一次。前端重试、后端重试、网关重试,任何一次重复用同一个 code 换 token 都会返回 40029(invalid code)。我们线上出现过代码里 axios 超时重试导致 code 被二次消费的故障。
  2. redirect_uri 必须做 urlencode,且域名必须与公众号后台配置的网页授权域名完全一致,差一个 www 都不行。
  3. 网页授权 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

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。