做小程序开发的朋友,多多少少都在登录这件事上栽过跟头。微信小程序登录 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 到处传,结果到后端已经失效,返回一个报错。这类问题最典型的错误码是40029(code 无效)、40013(AppID 无效),具体会在后面的问题表里统一列出。
服务端拿到 code 之后,用来换openid和session_key的请求是向微信服务器发起的 GET 请求,路径一般是:
https://api.weixin.qq.com/sns/jscode2session?appid=xxx&secret=xxx&js_code=xxx&grant_type=authorization_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_type=authorization_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;这里有三个细节建议重点看。
第一,登录接口是 POST,code 从 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 完成身份识别”,用户信息补全是下一步的事。
其次是请求封装。小程序没有 axios,wx.request就够用了,但我需要做一个基础封装,统一注入 token:
const 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这一步。错误码很多,我把实战里最常遇到的几个整理成了一张表:
| 错误码 | 含义 | 常见原因与处理 |
|---|---|---|
| 40029 | code 无效 | code 被用过了、过期了或根本不是当前小程序发的,重新调用 wx.login 换取新 code |
| 40013 | AppID 无效 | 后端配置的 AppID 与小程序后台不一致 |
| 40125 | AppSecret 无效或未正确配置 | 检查 AppSecret 是否复制错、是否已重置,环境变量加载是否成功 |
| 40226 | code 已耗尽 | 短时间内频繁用同一个 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”这三个场景,确认没有问题再提交。也建议你这样做,这几分钟的检查,能省掉未来不知道多少个深夜排查。