做小程序开发的朋友多多少少都在登录这件事上栽过跟头。微信小程序登录 demo 这个需求看着挺简单——调一个wx.login把 code 发给后端后端换一下openid再生成一个 token 返回。可真到自己写的时候要处理的东西比想象中多得多code 什么时候失效openid 能不能直接存数据库session_key 要不要发给前端token 过期之后前端怎么续这些问题任何一个处理不好轻则用户反复重新登录重则登录态被滥用安全上出漏洞。这篇内容我就把我实际搭过的一个微信小程序登录 demo 完整拆开讲清楚登录链路里每个环节的正确处理姿势并且给出前端、后端可以直接照抄的示例代码。适合刚接手小程序开发、正准备写登录功能的人也适合已经写完登录但总是遇到“用户突然掉登录”“code 无效报错”这类问题的开发者。1. 登录方案设计思路先想明白“快速安全”到底指什么1.1 先解决一个关键认知为什么不能把 openid 直接当登录凭证很多第一次做小程序登录的人看到wx.login能拿到一个 code后端拿 code 换回openid马上就会想既然openid能唯一标识用户那我前端把openid存起来以后每次请求都带上后端一比对就知道是谁了这不就是登录吗这个思路在极早期的 demo 里确实存在但实际项目里绝对不能这么干。openid是微信给当前小程序分配的用户标识它本身不是保密信息任何一个小程序前端代码里、接口日志里都可能出现它。如果前端把openid当成登录态等于把“用户身份”直接明文暴露在每一台请求报文里。更现实的麻烦是登录态必须是服务端可以主动吊销与续期的openid只是一个标识符它没有过期时间、没有签名服务端没有办法判断当前这次请求到底是不是用户本人发起的。所以我们的方案一定是在openid之上由后端再签发一个自定义登录态也就是 token。这一步是整个登录方案的地基先把这个想通后面就不会走歪。1.2 登录全链路拆解从 wx.login 到业务接口鉴权标准的微信小程序登录流程核心是“一个 code 换一个会话”。整个链路是这样的小程序端调用wx.login()拿到一个临时凭证 code。小程序端把 code 通过 HTTPS 请求发送到自己的后端服务。后端拿 code 加上 AppID、AppSecret去微信的jscode2session接口兑换openid和session_key。后端根据openid查询用户表用户不存在就创建存在就更新最近登录时间。后端用用户唯一 ID 签发一个自定义登录态 token有效期自己控制。后端把 token 返回给小程序端小程序端把它存到本地缓存。之后每次业务请求小程序端在请求头里带上 token后端通过鉴权中间件解析 token拿到用户 ID完成身份识别。这个链路里有一个容易被忽略的认知wx.login是静默操作它不会弹授权框也不需要用户主动确认所以它解决的是“这个用户是谁”而不是“我能不能拿到他的手机号、头像、昵称”。头像昵称、手机号这类信息在小程序里是通过官方提供的用户信息授权组件来获取的和登录是两条线。很多新手在这里会混在一起等后面真正做用户资料完善时再展开也不迟。1.3 会话态方案对比JWT 与 Session 到底怎么选code 换出来的openid是“锁链”的起点但那把锁本身要放在哪通常有两类做法。第一种是 Session 方案。后端在内存或缓存里存一份 session 记录key 是一个随机串value 对应用户 ID 和过期时间前端拿到的是这个随机串sessionId每次请求带着它后端查一下 session 存储就知道用户是谁。这种方案的优点是可以服务端主动踢人、注销逻辑直观缺点是要维护一份存储分布式部署时还要考虑 session 共享得引入缓存中间件。第二种是 JWT 方案。把用户 ID、过期时间、签名放在一个自包含的 token 里后端不存任何会话状态只要验证签名合法、未过期就能认定用户身份。优点是天然适合多实例、微服务扩容不用管 session 共享缺点是一旦签发出去在过期之前服务端无法主动让它失效。就登录 demo 而言我推荐直接用 JWT。理由很简单小程序登录场景里服务端对“主动吊销”的要求不高更多是依赖过期时间。而且 JWT 的实现成本低不用额外引入缓存一个签名库就能搞定。给它设一个合理的过期时间再配合后面的静默续期策略在小体量项目里完全够用。如果是中大型项目已经有缓存体系了或者有严格的“单设备登录”“强制下线”需求再考虑 Session 也不迟。2. 登录链路里的关键细节code、session_key、token 的底层逻辑2.1 code 是临时凭证时效、一次性与常见报错wx.login返回的 code官方设计的定位是“临时登录凭证”。它的特点有两个一是只能用一次用完即失效二是有效期比较短通常只有几分钟。正因为如此登录接口一定不能把 code 缓存起来反复用。注意code 不能复用不能缓存更不能写死在前端或者让多个页面共享同一个 code。实践中我会看到有人把 code 写死在请求参数里或者在多个业务页面拿到同一个 code 到处传结果到后端已经失效返回一个报错。这类问题最典型的错误码是40029code 无效、40013AppID 无效具体会在后面的问题表里统一列出。服务端拿到 code 之后用来换openid和session_key的请求是向微信服务器发起的 GET 请求路径一般是https://api.weixin.qq.com/sns/jscode2session?appidxxxsecretxxxjs_codexxxgrant_typeauthorization_code这里有一个绝对的底线AppSecret 必须保存在服务端配置里绝对不能出现在小程序代码里。因为小程序代码包被下载之后前端代码基本是明文可见的AppSecret 一旦泄露别人就能用它发起伪造的凭证兑换请求危害非常大。2.2 session_key 的安全边界服务端值守前端不碰session_key 是jscode2session返回数据里的另一个关键字段。很多人对它又爱又困惑爱是因为它能配合用户授权数据做校验与解密比如小程序里获取手机号的敏感数据就是通过 session_key 按照官方规定的算法解密的困惑是因为不清楚它到底该怎么存、怎么用。这里有一个我反复强调的原则session_key 只在服务端使用绝不能返回给前端。某些早期的 demo 习惯把openid和session_key一起发给前端让前端直接去解密数据这是非常危险的做法。session_key 本质上是一条会话密钥就像一把钥匙钥匙放到门外的人手里门锁就等于形同虚设。合理做法是后端在jscode2session拿到 session_key 后要么临时保存在服务端的内存或缓存里要么随用户记录一起保存但无论如何不要出现在接口响应体里。另外还要注意session_key 会随着每次wx.login刷新。如果你把用户旧的 session_key 存了下来下一次wx.login之后旧的那份就作废了。所以涉及解密手机号之类的场景正确的姿势是前端先触发一次wx.login拿到新 code 后调用后端对应的解密接口后端把 code 换成新鲜的 session_key再用它去解密用户提交的加密数据。不要在业务里假设 session_key 长期有效。2.3 自定义登录态的设计要点签名、过期与续期签发的自定义登录态我用的是 JWT但 JWT 的写法也有讲究。首先签名密钥要单独配置不要和 AppSecret 混用也不要把真实值提交到仓库。其次 token 里别塞太多敏感信息一般放一个用户 ID 就够了。真要放用户信息也是非敏感字段因为 JWT 的 payload 只是编码不是加密别人解开就能看到内容。过期时间怎么设登录 demo 我一般设 7 天但对不同业务可以调工具类小程序希望每次打开都是登录态可以设 30 天涉及资金操作的小程序建议 1 天甚至更短。过期后怎么续后面问题章节专门展开小程序端捕获 401静默调用wx.login重新换 token这个过程用户全程无感。签发、验证用现成的签名库就行。用库的时候记住一点永远指定算法不要允许库自动降级成弱算法这是安全审计里常见的高危项。代码里怎么写我会在下一章给到完整示例。3. 从零搭建登录 demo后端接口与前端链路的完整实操3.1 动手前准备AppID、AppSecret 与后端环境开始写代码之前先把三样东西准备好。AppID 和 AppSecret在小程序后台的基本设置里能看到。AppID 是公开的会出现在前端代码里AppSecret 是私密的必须配置在后端环境变量里。这里我额外提醒很多人把 AppSecret 写在后端源码里又推到公开仓库等于白设强烈建议用环境变量或配置中心管理。后端环境我下面示例用的是 Node.js 的 Express数据库用 MongoDB。选型不是关键思路完全可以用在任何语言和任何存储上。需要装好的依赖有express、axios用于请求微信接口、jsonwebtoken、mongoose。如果你用其它数据库跳过 mongoose 相关的建表部分即可。我假设读者有基础的 Node.js 和数据库使用经验所以不会手把手讲 npm 安装但会把关键代码逻辑拆到函数级别照着写就行。3.2 后端登录接口实现code 换 openid 再签发 token后端部分我拆成两个核心模块登录接口和鉴权中间件。先看登录接口的逻辑骨架const express require(express); const axios require(axios); const jwt require(jsonwebtoken); const User require(./models/User); const router express.Router(); const APPID process.env.WX_APPID; const APPSECRET process.env.WX_APPSECRET; const JWT_SECRET process.env.JWT_SECRET; const JWT_EXPIRES process.env.JWT_EXPIRES || 7d; router.post(/api/login, async (req, res) { const { code } req.body; if (!code) { return res.json({ code: MISSING_CODE, msg: 缺少 code }); } const url https://api.weixin.qq.com/sns/jscode2session ?appid${APPID}secret${APPSECRET}js_code${code}grant_typeauthorization_code; try { const resp await axios.get(url); const data resp.data; // 微信返回 errcode 即失败 if (data.errcode) { return res.status(400).json({ code: WX_LOGIN_FAIL, msg: data.errmsg, errcode: data.errcode }); } const { openid, session_key } data; // 需要单独处理解密场景时才保留 session_key否则不用入库 let user await User.findOne({ openid }); if (!user) { user await User.create({ openid }); } else { user.lastLoginAt new Date(); await user.save(); } const token jwt.sign( { uid: user._id.toString() }, JWT_SECRET, { algorithm: HS256, expiresIn: JWT_EXPIRES } ); res.json({ code: OK, data: { token } }); } catch (err) { res.status(500).json({ code: SERVER_ERROR, msg: err.message }); } }); module.exports router;这里有三个细节建议重点看。第一登录接口是 POSTcode 从 body 里来不要用 GET 直接把 code 拼在 URL 上这样能避免 code 出现在访问日志、历史记录里。第二session_key 我没有存数据库。原因很简单当前 demo 没有解密手机号这类需求存了反而增加泄露面。如果业务确实需要解密用户信息可以在这一步临时缓存 session_key配合响应式续期用完即弃。第三jwt.sign里我显式指定 HS256然后expiresIn用环境变量控制。密钥从process.env读取源码里永不出现真实值。鉴权中间件这样写const authMiddleware async (req, res, next) { const authHeader req.headers[authorization] || ; const token authHeader.startsWith(Bearer ) ? authHeader.slice(7) : ; if (!token) { return res.status(401).json({ code: UNAUTHORIZED, msg: 未登录 }); } try { const payload jwt.verify(token, JWT_SECRET, { algorithms: [HS256] }); req.userId payload.uid; next(); } catch (err) { return res.status(401).json({ code: TOKEN_INVALID, msg: 登录态失效 }); } };注意两点从 header 里取 token 时我兼容了 Bearer 前缀jwt.verify也显式指定了algorithms不让它自动选择算法。这样即使拿到一个伪造的弱算法 token也会被直接拒绝。3.3 前端小程序端实现wx.login 到 token 落库小程序端代码主要两件事登录动作和请求封装。先看登录动作。我把wx.login包成一个 Promise然后把 code 发到后端const loginWx () { return new Promise((resolve, reject) { wx.login({ success: resolve, fail: reject, }); }); }; const loginToServer async () { const { code } await loginWx(); const resp await stayFetch({ url: /api/login, method: POST, data: { code }, }); if (resp.data.code OK) { wx.setStorageSync(token, resp.data.data.token); return resp.data.data.token; } throw new Error(resp.data.msg || 登录失败); };这里要注意wx.login本身不需要用户授权所以它是“静默登录”很适合在页面onLoad里直接触发。但是用户第一次打开小程序如果后端查询用户表发现没有这个人通常还会引导用户去完善头像昵称、手机号等信息这部分不能靠wx.login要靠官方表单组件来做。demo 里我只做了核心的“拿到 token 完成身份识别”用户信息补全是下一步的事。其次是请求封装。小程序没有 axioswx.request就够用了但我需要做一个基础封装统一注入 tokenconst stayFetch (options) { const token wx.getStorageSync(token); return new Promise((resolve, reject) { wx.request({ url: https://your-domain.com options.url, method: options.method || GET, data: options.data || {}, header: { content-type: application/json, ...(token ? { Authorization: Bearer ${token} } : {}), }, success: (res) { if (res.statusCode 401) { // 登录态失效后面单独讲续期策略 reject(new Error(UNAUTHORIZED)); return; } resolve(res); }, fail: reject, }); }); };封装之后所有业务请求只需要调用stayFetch就会自动带上 token后端返回 401 时也能统一拦截。这个封装我建议所有页面统一引用而不是每个页面各写一遍wx.request。3.4 串联完整请求链路页面启动、请求拦截与 401 兜底把上面两块拼起来一整套请求链路就通了。用户打开小程序首页页面onLoad里先检查本地缓存没有 token调用loginToServer静默完成登录拿到 token 存起来。有 token直接进入业务请求。业务请求返回 401说明 token 过期或已被篡改此时重新走一遍loginToServer拿到新 token 后重放刚才的请求如果重试还是 401再把用户踢回登录引导页。一个简化版的页面启动逻辑const ensureLogin async () { const token wx.getStorageSync(token); if (token) return token; return loginToServer(); }; Page({ async onLoad() { await ensureLogin(); const resp await stayFetch({ url: /api/profile, method: GET }); // 用 resp 渲染页面 }, });这里要注意一个体验问题如果网速慢wx.login和后端请求叠加用户会看到白屏。所以登录动作不要阻塞页面渲染可以先渲染页面骨架后台静默登录拿到用户信息后再局部刷新。很多新手在页面onLoad里直接 await 登录结果页面空白好几秒体验非常差。4. 登录 demo 实战中的高频坑与排查手册4.1 后端报错速查表从错误码快速定位问题登录接口最常出问题的环节就是后端请求微信jscode2session这一步。错误码很多我把实战里最常遇到的几个整理成了一张表错误码含义常见原因与处理40029code 无效code 被用过了、过期了或根本不是当前小程序发的重新调用 wx.login 换取新 code40013AppID 无效后端配置的 AppID 与小程序后台不一致40125AppSecret 无效或未正确配置检查 AppSecret 是否复制错、是否已重置环境变量加载是否成功40226code 已耗尽短时间内频繁用同一个 code 发起请求说明前端把 code 缓存复用了-1系统繁忙通常为微信接口临时不可用做一次重试即可排查这类错误我有个习惯先看后端日志里 code 的前几位看它是不是每次都一样。如果一样基本可以断定前端把wx.login结果缓存在全局变量里重复使用了。正确做法是每次登录都重新调用wx.login也只调用一次用完即传。4.2 登录态过期别硬踢设计一套静默续期方案登录态过期是最容易被忽略的用户体验杀手。JWT 只要设置过期时间到期就失效如果前端不做续期用户明明还在用 App突然就被弹回登录页体验很割裂。我在项目里常用的方案是“请求层静默续期”核心思想是把所有 401 当成一次机会由统一封装层自动走一遍“wx.login - 换新 token - 重放原请求”的流程。伪代码let refreshingPromise null; const requestWithRetry async (options) { try { return await stayFetch(options); } catch (error) { if (error.message ! UNAUTHORIZED) throw error; if (refreshingPromise) { await refreshingPromise; } else { refreshingPromise loginToServer().finally(() { refreshingPromise null; }); } return stayFetch(options); } };这里有个关键设计并发请求同时收到 401 时只需要一个登录请求在跑其它请求等同一个刷新完成即可避免同一时间往微信接口打 N 个重复的 code 请求。用refreshingPromise缓存住那个登录 Promise就实现了请求合并。续期之后如果重放请求还是 401那才是真的登录失效这时候再跳登录引导页也不迟。这套逻辑放进统一请求层前端每个页面写业务代码完全无感。4.3 登录功能安全自查清单六个容易被忽略的点登录功能关系到用户体系我至少会自查下面几个点。AppSecret 是否只存在于后端。小程序代码里的任何字符串都是可以被扒出来的把 AppSecret 拼在前端请求参数里等于不设防。登录接口是否有频率限制。code、openid 都是可枚举或可重放的如果不加频控接口很容易被刷。建议按 IP 和按用户维度做基本的限流。是否记录敏感日志。不要完整打印jscode2session的响应包含 session_key 和 openid 的日志要做脱敏。日志是排障用的不是给攻击者留教材的。token 是否只放在 header。不要把 token 拼在 URL query 里那会进日志、进历史记录也会被中间环节看见。是否绑定用户 ID。鉴权中间件拿到的uid必须作为查询数据的依据不能只在接口里随意信任前端传的 userId 参数。后端是否校验来源。线上环境建议配合服务端到服务端的签名体系防止登录接口被当成公开接口刷。其中“token 保持放在 header 里传递而不是 URL”是我见过被提得最多、又最容易犯的低级安全错误写 demo 的时候就要养成习惯。4.4 开发者工具、体验版与真机调试的差异提醒调试登录功能时开发者工具和真机有几次让我印象深刻的差异。一是 code 的有效期。开发者工具里如果打开了“不校验合法域名”之类的调试选项有时 code 的状态、接口报错表现和真机不一致。遇到“开发者工具正常、真机报 40029”先看看是不是工具版本缓存了旧的 code或者工具模拟时把wx.login结果给缓存了。真机上请关闭调试里的缓存选项再试。二是合法域名配置。小程序后台配置的 request 合法域名是 HTTPS并且需要在服务器域名配置里加入白名单。开发者工具可以勾选跳过这个检查但真机不会。登录 demo 刚写完时最常见的“真机请求失败”九成是合法域名没配置或者证书过期。调试阶段可以先把“不校验合法域名”勾上真机预览时再按正式要求配置好域名两者切换时不要忘了检查。三是 session_key 与解密时机。真机上wx.login触发的频率、时机和开发者工具有差异依赖 session_key 解密的业务在真机上表现可能和工具里不一致。这类问题没有捷径就是线上环境单独跑一遍完整链路别只在开发者工具里验证完就上线。写到这里登录 demo 的链路、代码、坑都过完了。最后分享一个小习惯在我自己项目里登录相关代码从来不会早早上线“先放着”因为登录是几乎所有请求的前置依赖一旦有安全问题波及面是整个用户体系。我会在写完第一版之后花半小时检查一遍 AppSecret 是否有泄漏渠道再跑一遍“清缓存重新登录、token 过期重放、并发 401”这三个场景确认没有问题再提交。也建议你这样做这几分钟的检查能省掉未来不知道多少个深夜排查。