微信扫码注册登录现在几乎是网站应用的标配能力,尤其做面向普通用户的 PC 端产品、To B 管理后台或者独立部署的业务系统,扫码登录都能直接砍掉一截账号体系成本。用户不用记密码,站方也不用发验证码,一套 OAuth2.0 授权码流程就能把身份验证这件事转交给微信。今天这篇不写空话,就基于 Spring Boot + Vue 这套常见技术栈,把微信开放平台网站应用的扫码注册登录从账号申请、参数配置、后端换 token、前端扫码回调,一直写到上线前的常见坑和安全加固。你要是正准备接手登录模块,或者刚被分配了一个“做个微信扫码登录”的需求,可以直接拿这篇当落地清单用。
1. 扫码登录背后的思路拆解:为什么是 OAuth2.0 授权码模式
1.1 我要解决的业务场景到底是什么
先说场景。最常见的需求是这样的:一个网站应用需要支持用户用微信扫码完成注册和登录。用户首次扫码时自动注册,之后每次扫码直接登录,站方后台能看到微信昵称、头像这些基础信息,后续可以再绑定手机号、补齐邮箱。这套东西放在 PC 网站、SaaS 系统、运维后台、企业内部系统上都成立。
这个需求里最大的难点不在于“弹个二维码”本身,而在于三个问题:怎么安全地拿到用户微信身份标识,怎么把微信身份和站内用户绑定,怎么让前端拿到登录态。微信开放平台的网站应用扫码登录,本质上是一套标准 OAuth2.0 授权码流程,我先把这一句话讲清楚,后面所有代码都围绕这句话展开。
1.2 为什么一定选授权码模式,而不是把密钥交给前端
要理解为什么用授权码模式,你得先知道微信为什么不能让你直接把用户信息暴露给一个“不可信”的前端页面。网站的 JS 代码是公开的,任何人都能打开 DevTools 看到请求参数,如果把 AppSecret 这种凭证放前端,等于把家里钥匙贴在大门上。
OAuth2.0 授权码模式的做法是:前端只负责把用户引导到微信的授权页,微信在用户确认之后,通过服务器端重定向给后端一个一次性的code,由后端拿着code去换access_token,再用access_token去拉用户信息。整个过程里,真正值钱的密钥(AppSecret)只存在于后端服务器和微信服务器之间的链路中。你把这个事理解成酒店前台流程:用户拿一张一次性餐券去前台,前台验证餐券后给用户一套带权限的门卡,而不是直接把总卡密码写在取号机上。
1.3 一次扫码背后实际流转了哪些环节
把整个时序过一遍,你在写代码前心里就有谱了:
- 用户打开网站登录页,点击“微信扫码登录”。
- 前端调用后端接口,后端生成一个随机
state并缓存到 Redis,同时拼接微信授权链接返回给前端。 - 前端用弹窗或 iframe 打开这个微信授权链接。
- 用户用手机微信扫码,在手机上确认授权。
- 微信前端将页面重定向(302)到后端配置好的回调地址,地址上带
code和state。 - 后端先校验
state,防止伪造授权请求。 - 后端用
code调用微信接口换取access_token和openid。 - 后端再用
access_token拉取微信用户信息,拿到昵称、头像。 - 后端用
openid查本地用户表,不存在则自动注册。 - 后端签发自己的登录凭证(比如 JWT),把前端重定向到登录成功页并携带 token。
- 前端保存 token,刷新登录状态。
这里code是一次性的,有效期只有五分钟,用过一次就作废,这也是授权码模式的安全基础。后面章节里的所有代码,都是把这一条链路逐段落地。
2. 微信开放平台账号申请与参数配置,这一步错了后面全白搭
2.1 网站应用创建流程,个人开发者最容易卡住
微信扫码登录不是拿公众号的 AppID 就能接的,必须走微信开放平台(open.weixin.qq.com),而且要创建“网站应用”。创建流程大概是:
- 注册开放平台开发者账号,完成开发者资质认证。
- 在“管理中心”里创建“网站应用”。
- 填写应用名称、官网地址、应用简介。
- 配置授权回调域。
- 提交审核,一般一两个工作日完成,审核通过后拿到 AppID 和 AppSecret。
这里最要命的是主体资质。个人开发者经常卡在这一步,因为网站应用目前主要面向企业或者组织类主体,个人主体开通限制很多,申请时要有企业相关的执照或认证材料。如果你只是个人项目自己测试,可以把思路转向微信测试号或者本地 mock,真要做生产环境,流程上绕不开企业资质。
注意:AppID 是公开的,可以出现在前端;AppSecret 是机密的,绝不能打包进前端代码或者提交到 Git。我见过有人为了省事把 AppSecret 直接写在 Vue 的
.env文件里,上线当天晚上账号就被刷爆了。
2.2 回调域名和 redirect_uri 之间的关系
开放平台里配置的是“授权回调域”,比如auth.example.com。你在代码里拼接redirect_uri参数时,这个参数的域名必须和配置的授权回调域完全一致,否则微信直接拒绝回调。
这里还有个很隐蔽的坑:redirect_uri参数必须做 URL 编码后再放进授权链接。很多新手拼接 URL 时不编码,或者编码后又被框架解了一层,回调时域名匹配不上,页面一直卡在白屏或者提示“redirect_uri 参数错误”。我在项目里常写的标准配置如下:
| 配置项 | 示例值 | 常见错误 |
|---|---|---|
| 授权回调域 | auth.example.com | 写成了https://auth.example.com/callback |
| redirect_uri | https://auth.example.com/api/auth/wechat/callback | 没做 URLEncode |
| scope | snsapi_login | 写成了公众号网页授权的snsapi_userinfo |
2.3 审核和上线前容易踩的三个小细节
创建应用审核时,一般要求应用有实际可访问的官网主页,并且网页内容里有清晰的应用介绍。我见过审核被拒的几个典型原因:官网打不开、页面是空模板、应用名称和网站内容对不上。提交前自己先拿无痕浏览器验证一遍重要页面能不能正常访问。
另外,授权回调域配置之后不要频繁改。每次修改虽然不需要重新审核,但如果正在线上跑流量,老回调链接会出现短暂不匹配问题。最好先在测试域名上把所有联调做完,再把生产环境的域名一致替换。
3. Spring Boot 后端实现:令牌换取、用户绑定与会话签发
3.1 用户表设计,openid 才是真正的身份锚点
先想清楚库表结构再写接口。微信扫码登录对接的用户表,最关键的是要有openid字段。openid是用户在某个应用(同一个 AppID)下的唯一标识,同一个微信用户在不同 AppID 下的openid不一样。这个字段必须建唯一索引,防止并发扫码导致重复创建用户。
我这里给一个常见的精简设计:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `openid` varchar(64) NOT NULL COMMENT '微信开放平台下的唯一标识', `nickname` varchar(64) DEFAULT NULL, `avatar` varchar(512) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `last_login_time` datetime DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;如果你接入了多个微信产品(公众号、小程序、网站应用)并且做了同主体绑定,理论上可以拿到unionid去做账号打通。但在只做网站扫码登录的场景下,openid已经完全够用。不要为了以后可能存在的多端打通去过度设计,先满足当前需求,留一个unionid字段备用即可。
3.2 生成扫码链接的后端接口,state 要进 Redis
后端先提供一个接口,比如GET /auth/wechat/qr-url,前端点开弹窗时调用。这个接口的核心动作是:生成随机state,把state存 Redis 并设置 120 秒过期,拼接微信授权 URL。
示例代码如下:
@RestController @RequestMapping("/auth/wechat") public class WechatLoginController { @Value("${wechat.appid}") private String appid; @Value("${wechat.redirect-uri}") private String redirectUri; @Resource private StringRedisTemplate stringRedisTemplate; @GetMapping("/qr-url") public Result<QrUrlVO> getQrUrl() { String state = UUID.randomUUID().toString().replace("-", ""); stringRedisTemplate.opsForValue().set("wechat:state:" + state, "pending", 120, TimeUnit.SECONDS); String url = "https://open.weixin.qq.com/connect/qrconnect" + "?appid=" + appid + "&redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8) + "&response_type=code" + "&scope=snsapi_login" + "&state=" + state + "#wechat_redirect"; return Result.ok(new QrUrlVO(url, state)); } }为什么state要进 Redis?因为回调接口收到state后必须核对它是不是我们下发出去的。如果不校验,攻击者可以构造一个假回调引导用户绑定到恶意账号,这就是典型的登录 CSRF。Redis 里把state和时间绑定,过期值设为 120 秒,用户扫码慢一点可能就过期了,但这是安全性和用户体验的合理折中,过期后提示用户重新生成二维码即可。
3.3 回调接口:校验 state、用 code 换 access_token
微信在用户扫码确认后,会把浏览器重定向到redirect_uri?code=xxx&state=xxx。后端在这个回调里按顺序做四件事:
- 校验
state是否在 Redis 中存在且匹配。 - 用
code请求微信的sns/oauth2/access_token接口。 - 用返回的
access_token和openid去sns/userinfo拉用户信息。 - 根据
openid查本地用户,做登录或注册。
第二步请求微信接口时,推荐用 RestTemplate 或者 OkHttp,代码大概这样:
@GetMapping("/callback") public void callback(@RequestParam("code") String code, @RequestParam("state") String state, HttpServletResponse response) throws IOException { // 1. 校验 state String cached = stringRedisTemplate.opsForValue().get("wechat:state:" + state); if (StringUtils.isBlank(cached)) { response.sendRedirect(frontendUrl + "/login?error=state_invalid"); return; } // 用过的 state 立即删除,防止二次使用 stringRedisTemplate.delete("wechat:state:" + state); // 2. 换取 access_token String tokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token" + "?appid=" + appid + "&secret=" + secret + "&code=" + code + "&grant_type=authorization_code"; String tokenResp = restTemplate.getForObject(tokenUrl, String.class); JsonNode tokenJson = objectMapper.readTree(tokenResp); if (tokenJson.has("errcode")) { response.sendRedirect(frontendUrl + "/login?error=wechat_error&detail=" + tokenJson.get("errmsg").asText()); return; } String accessToken = tokenJson.get("access_token").asText(); String openid = tokenJson.get("openid").asText(); // 3. 拉取用户信息 String userInfoUrl = "https://api.weixin.qq.com/sns/userinfo" + "?access_token=" + accessToken + "&openid=" + openid; String userResp = restTemplate.getForObject(userInfoUrl, String.class); JsonNode userJson = objectMapper.readTree(userResp); // 4. 处理本地用户 User user = userService.findOrCreate(openid, userJson); String jwt = jwtUtils.generateToken(user.getId()); // 前端重定向时带上 token response.sendRedirect(frontendUrl + "/login-callback?token=" + jwt + "&state=" + state); }这里有一点必须强调:code换access_token的请求一定要放在后端,不能放在前端。appsecret请求参数如果暴露在浏览器里,攻击者就能冒充应用去换取任意微信用户的身份,这属于事故级漏洞。
3.4 注册绑定逻辑,自动建号的细节坑
userService.findOrCreate里其实藏着很多新手会踩的坑。微信返回的nickname是用户自己的名字,可能带表情符号、可能重复、可能很长。如果你把nickname设成数据库唯一字段,两个微信用户都叫“张三”就会直接炸掉。我的建议是:用户名允许重复,唯一约束只放在openid上。如果业务上必须要求站内用户名唯一,就在注册时拼上随机后缀,比如“张三_8f3k”。
另外,每次扫码登录时最好都从微信拉一次最新的nickname和headimgurl并更新到本地。因为用户在微信里换头像后,如果本地一直存旧头像,两周后用户就会觉得你的系统“数据不同步”。当然,拉用户信息接口有频率限制,不要每次页面刷新都去拉微信,只在扫码登录成功这个时机更新一次。
JWT 签发时,payload 里放用户 ID、签发时间、过期时间就够了,不要放openid和access_token。JWT 是要放在浏览器 localStorage 里的,存越多的敏感信息,泄露面越大。我用 JWT 的过期时间一般设成 7 天,后台管理系统视安全要求缩到 2 小时。
4. Vue 前端实现:扫码弹窗、登录回调与登录态保存
4.1 弹窗扫码方案,iframe 和独立窗口怎么选
前端有两个方案可以把用户送到微信授权页:iframe 内嵌二维码,或者window.open打开独立窗口。
iframe 的优点是不脱离当前页面,用户视觉上“没有离开网站”。微信的qrconnect页面本身允许被 iframe 嵌套,所以在大多数浏览器环境里都能正常显示。但 iframe 有一个实际麻烦:用户扫码确认后,微信页面会把自己重定向到后端回调,后端再重定向到前端登录回调页。如果这一切都发生在 iframe 里,顶层页面并不知道 iframe 内部已经跳转完成,你拿不到跨域 iframe 的状态。
独立新窗口方案更稳。点击登录按钮后window.open打开微信授权地址,主页面继续停留,同时前端以轮询方式向后端查询扫码结果。用户看完授权页后手动关掉窗口,或者前端检测到成功后自动关窗。这个方案在几十个真实项目里跑下来,基本没有浏览器兼容性问题。
4.2 弹窗组件和轮询逻辑,这样写最清晰
配套的还有一个底层数据库访问代码,选型需要自行控制。先看前端页面,点击微信登录按钮时,调用后端接口拿授权链接:
const openWechatLogin = async () => { const { data } = await axios.get('/auth/wechat/qr-url') const qrUrl = data.data.url const state = data.data.state // 打开微信授权窗口 window.open(qrUrl, 'wechatLogin', 'width=700,height=600') // 启动轮询 startPolling(state) }轮询接口就是后端额外加的一个查询状态接口,比如GET /auth/wechat/status?state=xxx。后端在用户被重定向回callback并且成功签发 JWT 后,把 JWT 临时存到 Redis,key 为wechat:login:success:{state},再让前端轮询取走。
let timer = null const startPolling = (state) => { timer = setInterval(async () => { const { data } = await axios.get('/auth/wechat/status', { params: { state } }) if (data.data && data.data.token) { clearInterval(timer) localStorage.setItem('token', data.data.token) location.href = '/' } }, 1500) // 90秒没扫,停掉轮询 setTimeout(() => clearInterval(timer), 90000) }轮询间隔 1.5 秒比较平衡。间隔太短会对后端和 Redis 造成额外压力,太长用户扫码后会有明显等待感。临时 JWT 在 Redis 里的过期时间设 5 分钟,基本等于用户扫码的完成时限。
4.3 登录成功后的状态管理
Vue 这边我建议把 token 存在 localStorage,同时在状态管理库(Pinia 或 Vuex)里维护一个isLogin状态。路由守卫里每次跳转前读取 token 并解析是否过期,这部分网上资料很多,不展开讲。
要特别提醒的是,扫码登录后页面跳转携带 token 的形式,在真实部署里要注意 URL 长度不会超限,因为 JWT 可能比较长。更好的做法是后端回调时只返回一个一次性login_code,前端再拿着login_code去后端换正式 JWT。不过对常规内部系统来说,直接在重定向 URL 带 JWT 也能接受,但日志里记得别把 URL 整条打印,否则 JWT 就泄露了。
5. 高频故障排查与上线安全加固,这里都是能直接抄的实操经验
5.1 扫码登录高频疑难杂症排查表
我把自己踩过的和身边同事踩过的问题整理成一张表,基本覆盖了 95% 以上的接入问题:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 二维码一直转圈加载不出来 | 授权回调域没配置,或 redirect_uri 没编码 | 核对开放平台上回调域,检查 redirect_uri 前后端拼装后是否被转码 |
| 回调后提示 state 无效 | state 过期或缓存 key 拼写不一致 | 确认 Redis 过期时间,检查 state 前后是否加了多余空格 |
| 用 code 换 token 报 40029 | code 只能使用一次,可能回调接口被重复调用 | 在回调接口做幂等,借助日志确认请求次数 |
| 拉取用户信息报 48001 | scope 不是 snsapi_login,或接口权限未开通 | 确认 scope 参数,确认网站应用审核已完成 |
| 内嵌 iframe 时微信页面空白 | 部分浏览器禁止第三方 Cookie 或嵌套受限 | 改用独立窗口打开授权链接,或直接整页跳转 |
| 页面提示无法从此网站添加应用扩展或用户脚本 | 浏览器安全设置或扩展拦截了登录页脚本 | 换无痕模式或换个浏览器测试,同时检查页面 CSP 是否限制了外部脚本 |
这里有一条通用排查经验:微信接口返回的errcode和errmsg一定要打印到日志。限制微信接口的正常返回也会给你返回errcode,别把errcode当成正常 JSON 字段忽略掉,它往往是定位问题最快的线索。
5.2 安全加固,这几条上线前必须检查
第一,AppSecret 绝不能写进代码仓库。很多团队喜欢把配置写在application.yml里然后整个项目传到 Git,这等于公开了密钥。正确做法是放到环境变量、配置中心或者密钥管理服务里,并且定期轮换。第二,回调接口要用state做双重校验,而且用完立即删除,防止重放攻击。第三,JWT 的签名密钥要足够长,32 个字节以上,不能是 123456 这种弱密钥。第四,日志脱敏。code、access_token、openid这些字段在日志里打码,或者只打印后半段。第五,用户信息接口的调用频率要限制,微信对sns/userinfo有频率控制,突发流量下容易限流,可以在本地 Redis 里对同一个openid加一分钟缓存。
还有一点不太有人提:扫码登录页本身的接口也要做限流。否则有人写脚本高频请求qr-url,每次都往 Redis 里塞state,能把 Redis 内存打爆。我在生产里给这个接口做过一个简单的每秒并发限制,成本很低但效果很明显。
5.3 登录模块上线自检清单
登录是系统的门,上线前最好对着清单逐项过一遍:
- 开放平台授权回调域与生产环境 redirect_uri 完全一致
state入库且有过期时间,回调时校验并删除AppSecret从环境变量读取,不落 Git- 前端 token 存储在安全位置,不放进 URL 以外的日志
- 回调、轮询接口有基本限流
openid字段建了唯一索引- 日志中检索不到明文
access_token和code - 微信返回的非 0 错误码都有日志记录和前端提示
我接过的项目里,凡是上线后出问题的,八成都是前三条没做到位,尤其是回调域名不一致和 state 校验缺失。这两个问题排查起来特别费劲,因为前端看到的永远只是“登录失败”四个大字,用户压根不会去翻网络请求里的重定向地址。
微信扫码登录本身不复杂,复杂度主要藏在配置细节和流程安全性里。把这套流程完整走一遍,你收获的不只是“会调接口”,而是对 OAuth2.0 授权码模式和前后端会话管理有了一个真实的工程化认知。以后接 GitHub、Google、飞书登录,思路完全一样,只是换了个授权服务器和参数名。真到那时候你会感谢当初把 state 校验和 Redis 过期时间都认真写了的自己。