小程序登录态过期:wx.login 静默续期与并发刷新
用户隔了两天再打开小程序,点「我的订单」转了半天圈,最后被弹回首页。客服给的方案是「退出重进一下就好了」。这真不是网络问题——是登录态过期之后,代码里没有人管它。
利益相关:我在南京易企达做交付,微信小程序是主要业务之一。文中的项目信息已脱敏,代码是项目里实际跑过的简化版。
上两篇写的是启动速度和首屏渲染,这篇写上线之后才会暴露的问题:登录态。它不像性能问题那样有明确的耗时曲线,但只要用户隔夜回来、切个设备、改个密码,就会以「白屏、转圈、被踢」的形式出现在客服群里。
我把它拆成四件事:哪四层会过期、静默续期怎么闭环、并发刷新为什么会把 token 刷坏、哪些情况根本不该静默续。
一、先把「过期」拆成四层
很多人把 wx.login 当成「登录」,其实它只做一件事:换一张一次性票据。真正的登录态在我们自己的服务端。一次登录链条上有四层东西,任何一层失效,表现都是「点了没反应」。

| 层级 | 生命周期 | 失效后的典型报错 | 能不能静默恢复 |
|---|---|---|---|
code |
5 分钟,且只能消费一次 | 40029 / 40163 | 能,重新 wx.login |
session_key |
微信不承诺时长,静默失效 | 解密失败(-41003) | 能,重新换票 |
| 自建 token | 我们自己定,建议 2 小时 | 401 / 40101 | 能,续期 |
| 用户授权 scope | 长期,但用户可随时撤销 | 业务接口无权限 | 不能,需用户重新授权 |
这张表是后面所有讨论的基础。判断一个「登录问题」该怎么修,第一句话永远是:失效的是哪一层?
顺带说一个常见误解:小程序里不需要 refresh_token。
refresh_token 是 Web 端的产物——浏览器拿不到用户的微信身份,只能靠一个长期凭证去换短期凭证。小程序不一样,wx.login 在任何时候都能重新拿到 code,用户全程无感。再设计一层 refresh_token,等于给一个已经能自愈的链路多加一个会过期的状态,多一处能坏的地方。
二、静默续期的最小闭环
去掉所有业务包装,续期只有五步:

- 小程序调
wx.login()拿 code; - 把 code 发给自己的服务端;
- 服务端调
code2Session,换回openid和session_key; - 服务端落库用户,签一个自己的 token,返回 token、过期时间、服务端当前时间;
- 之后所有业务请求带
Authorization: Bearer <token>。
服务端代码大概是这样(Node 示例,PHP 同理):
app.post('/auth/login', async (req, res) => {
const { code, reason } = req.body || {};
if (!code) return res.json({ code: 40000, msg: 'missing code' });
const url = 'https://api.weixin.qq.com/sns/jscode2session'
+ `?appid=${APPID}&secret=${SECRET}&js_code=${code}&grant_type=authorization_code`;
const wxRes = await fetch(url).then((r) => r.json());
if (wxRes.errcode) {
// 40029 code 无效 / 40163 code 已被使用 / 45011 频率限制
return res.json({ code: wxRes.errcode, msg: wxRes.errmsg });
}
const { openid, unionid, session_key } = wxRes;
// session_key 只落在服务端,永远不下发给前端
const user = await upsertUser({ openid, unionid, sessionKey: session_key });
const expireAt = Date.now() + 2 * 60 * 60 * 1000;
const token = sign({ uid: user.id, ver: user.sessionVersion }, expireAt);
res.json({
code: 0,
data: { token, expireAt, serverTime: Date.now() },
});
});
三个细节值得单独说:
session_key 不下发。 它等同于用户在微信侧的解密密钥,前端拿到没有任何好处,泄露了反而能被拿去解别人的密文。
过期时间由服务端给,客户端不要自己算。 写成 Date.now() + 2 * 3600 * 1000 的代码我见过太多,只要服务端哪天把有效期改成 30 分钟,客户端就永远不知道自己已经过期。
serverTime 一起返回。 后面讲时钟问题时要用它。
三、坑一:onLaunch 和首页各调一次 wx.login
这是最常见的并发源头。app.js 的 onLaunch 里登录一次,首页 onLoad 里怕没登录又登录一次,「我的」页面再补一次。平时看不出来,因为三次请求几乎同时发出,谁先回来谁赢。
但 code 是一次性的。两个并发请求拿着两个 code 去换票,本身都能成功,问题是同一用户第二次 code2Session 会让前一次的 session_key 失效。如果这期间正好有解密手机号、解密敏感数据的操作,就会莫名其妙报 -41003。
更常见的是另一种:某个页面把「登录成功」当成事件广播出去,多个监听者各自发起业务请求,服务端瞬间收到一批带旧 token 的请求。
结论很简单:登录入口全项目只能有一个。
// utils/auth.js
let loginTask = null;
export function ensureLogin() {
if (getToken() && !isExpiringSoon()) return Promise.resolve(getToken());
if (loginTask) return loginTask; // 并发调用收敛到同一个 Promise
loginTask = refreshSession('ensure')
.finally(() => { loginTask = null; });
return loginTask;
}
onLaunch、页面 onLoad、请求拦截器,全都只调 ensureLogin()。谁先到谁发起,后面的全部等待同一个 Promise。
四、坑二:并发刷新把 token 刷坏
真正麻烦的是 token 过期的那一刻。首页同时有 5 个接口在飞:商品、购物车、消息、配置、推荐。服务端几乎同时返回 5 个 401。
如果每个 401 都触发一次刷新,会发生什么?

- 5 个请求各自
wx.login,拿到 5 个 code; - 服务端处理 5 次换票,签发 5 个 token;
- 如果服务端是「单 token 模式」(新 token 签发即旧 token 失效),后返回的 token 会把先返回的顶掉;
- 先拿到新 token 的请求已经重放了,带着一个刚被顶掉的 token,又收到 401;
- 再触发刷新,循环开始,用户看到的是无限转圈。
就算服务端允许多 token 并存,也只是把「刷坏」换成了「5 倍登录请求」。微信的 code2Session 有频率限制,超了就是 45011,这时候连正常的用户都登不进来。
解决办法是单飞(single-flight):同一时刻只允许一个刷新任务存在,其余调用者共享它的结果。
// utils/auth.js
let refreshTask = null;
let failCount = 0;
let blockedUntil = 0;
export function refreshSession(reason) {
if (refreshTask) return refreshTask; // 关键:复用,不再发一次
if (Date.now() < blockedUntil) {
return Promise.reject(new Error('AUTH_BACKOFF')); // 连续失败后先冷静一分钟
}
refreshTask = (async () => {
const code = await loginCode(); // code 5 分钟有效,只能换一次
const res = await new Promise((resolve, reject) => {
wx.request({
url: `${BASE_URL}/auth/login`,
method: 'POST',
data: { code, reason },
timeout: 10000,
success: resolve,
fail: reject,
});
});
if (res.statusCode !== 200 || res.data.code !== 0) {
throw new Error(`AUTH_FAILED:${res.data.code}`);
}
const { token, expireAt, serverTime } = res.data.data;
wx.setStorageSync('auth_token', token);
wx.setStorageSync('auth_expire_at', expireAt);
wx.setStorageSync('auth_clock_skew', serverTime - Date.now());
failCount = 0;
return token;
})();
refreshTask
.catch(() => {
failCount += 1;
if (failCount >= 3) blockedUntil = Date.now() + 60 * 1000;
})
.finally(() => {
refreshTask = null; // 成败都要释放,否则永远卡死
});
return refreshTask;
}
有一个细节很容易漏:refreshTask = null 必须放在 finally 里。只在成功时清空,一旦刷新失败,这个 Promise 会一直挂在内存里,后面所有请求都拿到一个已经 reject 的旧任务——看起来像「登录按钮点了没反应」,其实是代码在等一个永远不会重来的结果。
至于「等待队列」,用 Promise 的写法下不需要手写数组。5 个请求 await 同一个 refreshTask,天然就是排队;刷新完成后各自重放。只有老的回调风格封装(success/fail 一把梭的那种)才需要维护一个 pending 数组,在刷新成功后挨个重放。
五、坑三:把所有 401 都当成「过期」
这是第二个死循环来源。401 只说明「你没通过鉴权」,原因至少有三种,处理方式完全不同。
| 业务码 | 含义 | 正确处理 |
|---|---|---|
| 40101 | token 过期 | 静默续期,重放一次 |
| 40102 | token 非法(签名错、伪造) | 清本地态,回登录页 |
| 40103 | 账号态失效(改密、异地登录、封禁) | 清本地态,reLaunch 回登录页 |
如果 40103 也走「刷新再重放」,会得到什么?刷新是成功的,新 token 也是合法的,但账号版本号已经变了,新 token 一样被拒。于是刷新 → 重放 → 40103 → 再刷新,用户被困在一个永远转圈的页面里,而日志上看到的是一串成功的登录请求。
所以服务端要能区分这三种情况。我们用一个 sessionVersion:
// 校验中间件:版本号对不上,说明账号态变了
const payload = verify(token);
const user = await getUser(payload.uid);
if (!user) return res.status(401).json({ code: 40103 });
if (payload.ver !== user.sessionVersion) {
return res.status(401).json({ code: 40103 }); // 不是过期,是失效
}
改密码、管理员踢人、检测到异常登录,都只需要 sessionVersion += 1,该用户所有设备上的 token 立刻作废。这比维护一张 token 黑名单简单得多,也不会有清理问题。
前端配套的拦截器:
export function request(options, isRetry = false) {
return new Promise((resolve, reject) => {
wx.request({
...options,
header: {
...(options.header || {}),
Authorization: getToken() ? `Bearer ${getToken()}` : '',
},
success: async (res) => {
const code = res.data && res.data.code;
if (res.statusCode === 401 && code === 40101) {
if (isRetry) return reject(new Error('RETRY_401')); // 只重放一次
try {
await refreshSession('40101');
return resolve(await request(options, true));
} catch (e) {
return reject(e);
}
}
if (code === 40103) {
clearAuth();
wx.reLaunch({ url: '/pages/login/index?reason=kicked' });
return reject(new Error('KICKED'));
}
resolve(res);
},
fail: reject,
});
});
}
isRetry 这个参数不是可选项。没有它,一个真的校验不过的接口会把「刷新 → 重放」跑成无限递归,最后是 Maximum call stack 或者一堆重复请求把后端打挂。
六、坑四:用本地时钟判断过期
Date.now() 在小程序里并不可信:用户可能手动改过时间,设备时区可能是乱的,iOS 和 Android 的时钟精度也不一样。我见过最离谱的一台测试机,本地时间比真实时间慢了两天,token 明明早过期了,客户端还认为「还剩 40 小时」。
解法是登录响应里带 serverTime,客户端只维护一个偏移量:
// 登录成功时算一次,存起来
const skew = serverTime - Date.now();
wx.setStorageSync('auth_clock_skew', skew);
// 之后所有判断都用修正后的时间
function now() {
return Date.now() + (wx.getStorageSync('auth_clock_skew') || 0);
}
export function isExpiringSoon(advance = 5 * 60 * 1000) {
const expireAt = wx.getStorageSync('auth_expire_at') || 0;
if (!expireAt) return true;
return now() >= expireAt - advance - Math.random() * 60 * 1000;
}
最后那个随机数不是装饰。如果一批用户是在同一时间登录的(比如早高峰集中打开),他们的 token 会在同一分钟集体到期。加 0–60 秒的抖动,能把刷新请求摊开,避免整点打出一个尖峰。
另外,isExpiringSoon 不要放在每个请求里判断。每个请求都读一次 storage、算一次时间,量大了就是白白消耗;正确的位置是页面进入时和收到 401 时,前者提前续,后者兜底。
七、坑五:刷新失败之后怎么办
刷新失败有三种,处理策略完全不同:
wx.login失败:通常是微信侧或网络问题,可以重试一次,仍失败就按「未登录」处理,页面展示登录引导,而不是白屏;- 服务端 5xx 或超时:说明业务侧挂了,这时候继续刷新只会加重故障,应该退避,并用缓存渲染页面骨架;
- 40103:不要重试,直接清态。
我们踩过的一个具体坑:弱网下 wx.login 没有传 timeout,默认值偏长,用户在地铁里点一下「我的」,页面转 20 秒才回到登录页。后来统一改成 8 秒超时、失败重试一次,最坏情况 16 秒必有结果,比无限等待好得多。
还有一点:刷新过程中用户可能已经离开页面。wx.reLaunch 落地到一个不存在的页面栈,或者 Promise 回调里操作已卸载页面的 setData,都会报错。刷新回调里加一句页面存活判断,成本很低。
八、坑六:session_key 失效与手机号解密
老项目里解密手机号是用 encryptedData + iv,依赖当前的 session_key。这套逻辑有个隐蔽的前提:从登录到解密之间,session_key 没有变过。
一旦这期间发生过重新登录(切换账号、token 过期续期、别的页面又调了一次 login),session_key 就换了,旧密文用新 key 解,报 -41003。日志上看是「解密失败」,根因其实在登录链路上。
现在更推荐用 getPhoneNumber 的 code 模式,服务端直接换号,不再碰 session_key:
// 小程序端:button open-type="getPhoneNumber"
onGetPhoneNumber(e) {
if (!e.detail.code) return; // 用户拒绝授权
request({
url: `${BASE_URL}/auth/phone`,
method: 'POST',
data: { code: e.detail.code },
});
}
// 服务端:用 code 换手机号(access_token 走中控缓存)
const r = await fetch(
`https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${ACCESS_TOKEN}`,
{ method: 'POST', body: JSON.stringify({ code }) }
).then((x) => x.json());
如果暂时没法改,至少要在解密失败时重新 wx.login 一次再解,而不是直接把错误抛给用户。
九、把它们收成一个状态机
上面这些规则散在各处,很容易写着写着就走样。我们最后把它收敛成一个状态机,所有登录相关的判断只认这五个状态:

VALID:正常用;NEAR_EXPIRY:本地预判剩余不足 5 分钟,下次进页面时先续;REFRESHING:续期中,其他请求等待;REVOKED:账号态变了,不可续;LOGIN_REQUIRED:清态、回登录页,等用户重新授权。
状态机的价值不在于图好看,而在于排除了「顺手再加一个判断」的空间。团队里任何人想加一条 if (token) 的分支,都得先回答:这属于哪个状态?答不上来,说明这个判断不该加。
十、上线前自查清单
这十条是我们每个项目上线前都会过一遍的,照着查基本能避开上面所有坑:
- 全项目只有一处调用
wx.login,其余走ensureLogin(); - code 不缓存、不复用,任何地方都不存 code;
- token 与
expireAt成对存储,过期时间来自服务端; - 刷新是单飞的,且
refreshTask在finally里释放; - 401 重放带
isRetry,只重放一次; - 40101 / 40102 / 40103 分开处理,40103 不刷新;
- 刷新连续失败有次数上限和退避;
- 登录接口有 8–10 秒超时,弱网下有兜底页面;
session_key不出服务端,敏感接口响应不落缓存;- 首页、详情页等公开页面不强制登录,未登录也能看。
十一、改完之后的对比
同一个会员小程序,改造前后各跑了一周(数据已脱敏):
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 冷启动触发的登录请求 | 3 次 | 1 次 |
| 首屏 5 个接口同时 401 时的刷新次数 | 5 次 | 1 次 |
| 40163(code 重复使用)日均次数 | 217 | 0 |
| 登录接口峰值 QPS | 43 | 9 |
| 弱网下从点击到页面可用 | 4.8 秒 | 1.9 秒 |
真正有意义的不是最后一行,而是第三行。40163 归零,说明「并发拿 code」这条路径被彻底堵死了,后面所有的体验改善都是它的副产品。
登录态这块代码,写的时候总觉得「不就是个 token 吗」,上线之后才会发现它是整个小程序里最容易被并发和边界条件咬到的地方。把它当成一个有状态的子系统来做,比在每个页面里补 if 要省事得多。
关于作者:南京易企达网络科技有限公司,核心团队自 2010 年起从事软件定制开发,公司 2020 年注册于南京,专注微信小程序及配套管理后台定制。本文项目信息已脱敏。
浙公网安备 33010602011771号