公众号网页授权怎么做:snsapi_base 与 snsapi_userinfo 踩坑实录
上线当天下午,运营在群里甩来一张截图:用户打开活动页,白屏,报 redirect_uri 参数错误。同一套代码,测试环境跑得顺顺当当,正式环境一进就翻车。这种场景,做过公众号 H5 的人多半踩过。网页授权(OAuth2)这条链路本身不长,坑都藏在细节里:域名配置、code 的一次性、两种 token 的混淆、微信内外浏览器的行为差异。这篇文章按真实排查顺序把坑一个个过一遍,代码和报错都是第一手记录。
从一个 10003 报错说起
先说那次翻车的现场。活动页 m.xxx-h5.com 上的「立即参与」按钮,跳转链接拼的是:

https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=https%3A%2F%2Fm.xxx-h5.com%2Fwx%2Foauth%2Fcallback&response_type=code&scope=snsapi_userinfo&state=abc#wechat_redirect
用户点下去,微信直接弹「redirect_uri 域名与后台配置不一致」,错误码 10003。排查半天发现原因很朴素:公众号后台的「网页授权域名」里配的还是旧域名,上周运维换过一次域名,没人记得同步改微信后台。
这里把 10003 相关的经验先存下来,都是验证过的:
- 公众号后台配置的授权域名不带
https://前缀,只填m.xxx-h5.com这样的裸域名; - 配置时要下载
MP_verify_xxx.txt放到该域名的根目录,且必须能通过https://m.xxx-h5.com/MP_verify_xxx.txt直接访问到; - 授权域名每月只能修改三次,上线前先想清楚,别边调试边改;
- redirect_uri 的域名必须与配置完全一致,子域名也要单独配置,
a.xxx.com配了不代表b.xxx.com能用。
改完后台配置,等了几分钟生效,报错消失。但故事才刚开始。
两种 scope,到底怎么选
网页授权的 scope 有两个值:snsapi_base 和 snsapi_userinfo。选错的代价不是报错,而是体验或数据的隐性损失。两个值的核心差异见下表:
| 对比项 | snsapi_base | snsapi_userinfo |
|---|---|---|
| 用户是否需要确认 | 否,静默跳转 | 是,弹授权确认页 |
| 能拿到的数据 | 仅 openid | openid + 昵称、头像、性别等 |
| 是否能拿 unionid | 公众号绑定开放平台后可以 | 同左 |
| 体验成本 | 零感知,页面不中断 | 首次有一次确认动作 |
| 典型场景 | 会话识别、投票防刷、静默埋点 | 积分榜展示头像、评论昵称、个人中心 |
说白了,判断标准就一条:页面上要不要展示「这个人是谁」的信息。纯逻辑判断(这个人今天投过票没)用 snsapi_base,要渲染头像昵称才上 snsapi_userinfo。
有个容易被忽略的细节:已关注公众号的用户,如果从公众号会话或自定义菜单进入 H5,snsapi_userinfo 也会静默通过,不弹确认页;未关注的用户从分享链接进来,才会看到确认页。所以「snsapi_userinfo 会打断用户」这个顾虑,对关注用户其实不成立。反过来,如果你需要 unionid 来打通多应用身份,两种 scope 在绑定了微信开放平台的前提下都能拿到,不必为了 unionid 强上 userinfo。
授权链路全景
整条链路画成时序图是这样,后续排查问题时可以对照看卡在哪一步:
sequenceDiagram
autonumber
participant U as 用户微信客户端
participant S as 业务服务器(ASP.NET Core)
participant W as 微信服务器
U->>S: 请求需要登录的 H5 页面
S->>S: 检查会话, 无有效 openid
S->>U: 302 跳转到授权链接(scope 按需选择)
U->>W: 携带参数请求微信, userinfo 需确认授权
W->>U: 302 回跳 redirect_uri, 附带 code 和 state
U->>S: 携带 code 请求回调接口
S->>S: 校验 state, 用 Redis 抢占 code
S->>W: 用 code 换 web access_token 和 openid
W->>S: 返回 access_token/openid/refresh_token
S->>S: 拉取用户信息, 写入 Redis 缓存
S->>U: 下发业务会话 Cookie, 页面渲染
链路里第 9 步是事故高发区:code 换 token 这个请求,一旦因为超时重试、用户手速双击、前端重复发起了第二次,微信会直接返回 40163(code been used)。后面会讲服务端怎么防。
redirect_uri 域名校验之外的三个坑
10003 是显性报错,好查。另外几个坑更隐蔽,当时都是靠看微信文档和抓包才定位的。
坑一:redirect_uri 要 URL Encode。 redirect_uri 作为 query 参数传给微信时必须整体编码,漏了编码的话,& 之后的参数会被微信当作 authorize 接口自己的参数截断。当时卡住的表现是:微信回调回来了,但 code 后面的 state 丢了,服务端 CSRF 校验直接挂。
坑二:测试号和正式号的域名要分开管。 微信测试号有独立的授权域名配置,团队里两个人分别在测试号和正式号上调试,改来改去把正式号的验证文件覆盖了。后来约定:测试号只配 test.xxx-h5.com,正式域名不动。
坑三:多级 302 导致最终域名不一致。 网关层做过一次 http 到 https 的跳转,再经过一层短链服务,最后到达微信的 redirect_uri 已经不是当初配置的那个。排查办法是用抓包工具看微信收到的最终跳转地址,逐层核对。
原理剖析:code 为什么只能用一次
这一节讲机制,理解了机制,后面的幂等设计就是顺水推舟。
OAuth2 的 authorization code 模式里,code 是一个短期一次性凭证,设计初衷是防止授权结果被中间人截获后重放。授权码先经过浏览器(不可信信道)回传,服务端再拿它连同 appsecret(可信信道)去换 token——两段式交换的意义就在这里。如果 code 可以重复使用,截获者拿到一次就能反复换 token,安全性归零。所以微信的实现是:code 有效期 5 分钟,且只能消费一次,第二次消费返回 40163。
微信侧对「一次」的判定是按 code 粒度的全局状态,跟你的服务端是不是同一台机器无关。这就带来一个经典并发问题:
- 用户点按钮,前端发了回调请求 A,网络抖动,A 还没返回;
- 前端超时重试(或者用户不耐烦又点了一下),发出请求 B;
- A 和 B 几乎同时到达服务端,都拿着同一个 code 去微信换 token;
- 先到的成功,后到的报 40163,用户看到错误页。
防这个问题的办法是让 code 的消费在服务端变成互斥操作:谁先抢到锁谁来换,后到的要么等待结果,要么直接拒绝。下面这段代码就是当时落地的方案。
code 并发与幂等:服务端怎么防
环境:.NET 8 Web API,StackExchange.Redis 做分布式锁和缓存,HttpClient 走 IHttpClientFactory。完整回调处理如下:
// 依赖: .NET 8, StackExchange.Redis
// 配置: appsettings.json 的 WxOAuth 节点(AppId / AppSecret)
// 说明: redirect_uri 指向本控制器的 Callback 动作
[ApiController]
[Route("wx/oauth")]
public class WxOAuthController : ControllerBase
{
// Redis 数据库实例, 在 Program.cs 里注册为单例
private readonly IDatabase _redis;
// HttpClient 工厂, 避免手动 new 带来的连接耗尽问题
private readonly IHttpClientFactory _httpFactory;
// 强类型配置绑定, 见文末的 Options 定义
private readonly WxOAuthOptions _options;
public WxOAuthController(IDatabase redis,
IHttpClientFactory httpFactory,
IOptions<WxOAuthOptions> options)
{
_redis = redis;
_httpFactory = httpFactory;
_options = options.Value;
}
// 发起授权: 业务页发现无会话时, 先到这里拿授权链接
// scope 在这里写死为 userinfo, 纯识别场景可以换成 base
[HttpGet("start")]
public IActionResult Start(string returnUrl)
{
// state 用随机串防 CSRF, 同时把 returnUrl 存进 Redis
// 这样回调时才知道要把用户送回哪个业务页
// 10 分钟过期, 用户中途放弃后残留的 state 不会一直占内存
var state = Guid.NewGuid().ToString("N");
_redis.StringSet($"wx:state:{state}", returnUrl ?? "/",
TimeSpan.FromMinutes(10));
// redirect_uri 必须 URL 编码, 否则参数会被截断
var redirect = UrlEncoder.Default.Encode(
$"{_options.CallbackBase}/wx/oauth/callback");
var url = "https://open.weixin.qq.com/connect/oauth2/authorize" +
$"?appid={_options.AppId}" +
$"&redirect_uri={redirect}" +
"&response_type=code" +
"&scope=snsapi_userinfo" +
$"&state={state}#wechat_redirect";
// 302 到微信授权页, 由微信完成后续跳转
return Redirect(url);
}
// 授权回调: 微信带着 code 和 state 回跳到这里
[HttpGet("callback")]
public async Task<IActionResult> Callback(string code, string state)
{
// 先做 CSRF 校验, state 不存在说明回调可疑或已过期
var returnUrl = await _redis.StringGetAsync($"wx:state:{state}");
if (returnUrl.IsNull)
return BadRequest("非法 state, 拒绝处理");
// 用 SETNX 抢占 code, 30 秒内只有一个请求能继续
// 并发重复回调在这里被挡住, 不会打到微信接口上
var lockKey = $"wx:codelock:{code}";
if (!_redis.StringSet(lockKey, "1",
TimeSpan.FromSeconds(30), When.NotExists))
return Redirect(returnUrl);
// 拼 code 换 token 的请求, grant_type 固定为 authorization_code
var url = "https://api.weixin.qq.com/sns/oauth2/access_token" +
$"?appid={_options.AppId}" +
$"&secret={_options.AppSecret}" +
$"&code={code}&grant_type=authorization_code";
var client = _httpFactory.CreateClient("wx");
// 超时设置在命名客户端上统一配置, 避免单请求各自为政
// 微信出错时 HTTP 状态码仍是 200, 必须解析 body 里的 errcode
var result = await client.GetFromJsonAsync<WxTokenResult>(url);
// 40029 code 无效, 40163 code 已被使用, 都引导重新授权
// 这里不做区分, 重新走一遍 start 就能拿到全新 code
if (result.ErrCode != 0)
return Redirect("/wx/oauth/start?returnUrl=" + returnUrl);
// openid 是用户在公众号内的稳定标识, 静默授权也拿得到
// 会话用自签 Cookie, 不把微信 token 暴露给前端
var sid = IssueSession(result.OpenId, result.AccessToken);
Response.Cookies.Append("sid", sid, new CookieOptions
{
HttpOnly = true, // 前端脚本不可读, 防 XSS 偷会话
Secure = true, // 只走 https
MaxAge = TimeSpan.FromDays(7)
});
// 跳回业务页之前, 顺手清掉 state, 一次授权只认一次
await _redis.KeyDeleteAsync($"wx:state:{state}");
return Redirect(returnUrl);
}
}
这套改造上线后,40163 报错从每天几十次降到基本为零。核心就一点:code 的消费权在服务端用 Redis 抢占,而不是指望用户别手抖。
access_token 的缓存设计:别把两种 token 搞混
网页授权里会出现两种 access_token,名字一模一样,用途完全不同,混用是高频事故:
| 对比项 | 网页授权 access_token | 全局接口调用凭证 access_token |
|---|---|---|
| 获取方式 | code 换取(sns/oauth2 接口) | appid+secret 请求 cgi-bin/token |
| 作用对象 | 单个用户 | 整个公众号 |
| 有效期 | 7200 秒 | 7200 秒 |
| 刷新方式 | refresh_token 换新 | 中控服务器统一获取分发 |
| 主要用途 | 拉取/刷新单个用户信息 | 模板消息、菜单、群发等接口 |
| 换取成本 | 一次 code 消耗(一次性) | 每日有获取次数限额 |
当时接手的老代码里,每次回调都拿 code 换一次新 token,从来不缓存。结果高峰期微信接口响应慢,回调耗时逼近一秒。改造后的缓存服务长这样:
// 依赖: StackExchange.Redis, System.Text.Json
// 设计: key 按 openid 隔离, 过期时间留 100 秒安全余量
public class WxWebTokenCache
{
private readonly IDatabase _redis;
private readonly IHttpClientFactory _httpFactory;
private readonly WxOAuthOptions _options;
public WxWebTokenCache(IDatabase redis,
IHttpClientFactory httpFactory, IOptions<WxOAuthOptions> options)
{
_redis = redis;
_httpFactory = httpFactory;
_options = options.Value;
}
// 取当前用户的 web access_token, 优先走缓存
// refresh_token 由调用方在建立会话时自行持久化
public async Task<string> GetTokenAsync(string openId, string refreshToken)
{
// 缓存 key 带上业务前缀, 方便排查和多环境隔离
// openid 直接拼 key, 同一用户只会有一条记录
var key = $"wx:webat:{openId}";
var cached = await _redis.StringGetAsync(key);
if (!cached.IsNull)
return cached; // 命中缓存直接返回, 不请求微信
// 走到这里说明缓存失效, 属于低频路径, 请求量可控
// 未命中则用 refresh_token 换新 token, 避免重新走授权
// 用户无感知, 页面不会出现授权确认的闪烁
var url = "https://api.weixin.qq.com/sns/oauth2/refresh_token" +
$"?appid={_options.AppId}&grant_type=refresh_token" +
$"&refresh_token={refreshToken}";
var client = _httpFactory.CreateClient("wx");
var result = await client.GetFromJsonAsync<WxTokenResult>(url);
// 42030 之类的报错说明 refresh_token 也失效了
// 这种情况只能引导用户重新走一次授权链接
// 返回 null 由上层决定降级策略, 本层不做跳转
if (result.ErrCode != 0)
return null;
// 写缓存, 7000 秒而不是 7200 秒, 防止临界点上的过期竞态
// 换新后返回的 token 才是可用的, 别把旧 token 再写回去
await _redis.StringSetAsync(key, result.AccessToken,
TimeSpan.FromSeconds(7000));
return result.AccessToken;
}
}
两个设计要点:key 按 openid 隔离,单个用户的 token 失效不影响别人;过期时间取 7000 秒而不是满额 7200 秒,留出时钟偏差和网络延迟的余量。改造后回调平均耗时从 800ms 左右降到 300ms 以内,大头就是省掉的换 token 请求。
微信内与外部浏览器的行为差异
网页授权只在微信客户端内有效。用户把链接复制到手机浏览器、电脑浏览器里打开,authorize 页面不会出现授权逻辑,表现是白屏或提示「请在微信客户端打开」。这个差异在分享场景里特别容易漏测——链接被转发出去,接收方用 QQ、浏览器打开就翻车。
推荐的分流策略画成流程图:
flowchart TD
A["用户打开 H5 链接"] --> B{"UA 是否包含 MicroMessenger"}
B -- "是" --> C["走公众号网页授权流程"]
B -- "否" --> D{"页面是否强依赖 openid"}
D -- "是" --> E["引导跳转扫码登录或提示用微信打开"]
D -- "否" --> F["降级为游客浏览, 不做授权"]
C --> G["按需选择 snsapi_base 或 snsapi_userinfo"]
G --> H["回调拿到 openid, 建立业务会话"]
落地时有几点经验:
- UA 判断放在服务端或入口中间件做,别等页面渲染完了才靠前端 JS 判断,晚一步白屏就出现了;
- 强依赖身份的页面(个人中心、订单列表),外部浏览器场景走开放平台网站应用的扫码登录,扫码登录拿到的也是 unionid 体系,可以和公众号打通;
- 弱依赖页面(活动介绍、内容浏览)直接降级为游客态,别硬跳扫码,转化率会很难看。
刷新 token 的边界
refresh_token 的规则容易记岔,这里把边界说清楚:
- 有效期 30 天,期间可以用它反复换新的 web access_token,refresh_token 本身一般保持不变;
- 用户在微信里取消授权或清空数据后,refresh_token 失效,只能重新走授权链接;
snsapi_base授权只拿 openid,没有用户信息可刷新,刷新动作对它没有意义——场景上就是拿 openid 查你自己的数据库;- 网页授权 token 和 openid 的缓存都以用户为粒度,跨设备不共享,用户换手机后要重新授权。
实践建议是把网页授权当一次性登录事件处理:授权成功后建立自己的会话体系(Cookie + 服务端 session),后续请求只认自己的会话,微信 token 只在需要调用户相关接口时才用。别试图拿网页授权 token 维持长期登录态,那不是它的设计目标。
改造前后对比
这套方案落地前后,几个关键指标的变化(来自我们自己的日志统计,非通用数据):
| 环节 | 改造前 | 改造后 |
|---|---|---|
| 40163 报错 | 日均 20+ 次 | 基本归零 |
| 10003 域名报错 | 每次换域名必现 | 上线核对清单后未再出现 |
| 回调平均耗时 | 800ms 左右 | 300ms 以内 |
| access_token 获取 | 每次回调都换新 | Redis 缓存 7000 秒 |
| 高峰期白屏反馈 | 运营每周收到 | 近一个月零反馈 |
常见误区澄清
几个反复被问到的认知偏差,集中澄清一下:
- 「配了授权域名,所有子域名都能用」——不对,每个子域名都要单独配置,配置数量还有上限;
- 「code 过期重新授权就行,重复用一次没关系」——code 是全局一次性状态,重复使用必定 40163,必须服务端幂等;
- 「外部浏览器加个参数也能走网页授权」——不行,scope 协议只在微信客户端内生效,外部场景请走扫码登录或游客降级;
- 「两种 access_token 是一回事」——作用对象和接口体系完全不同,混用的表现往往是「模板消息发出去全是 invalid credential」。
网页授权这条链路,协议本身十几年没大变,坑全在工程细节里。域名配置建一份上线核对清单,code 消费做幂等,token 分开缓存,微信内外分流处理——四件事做到位,这条链路就稳了。有更刁钻的坑,欢迎评论区交流。
参考与延伸
- 微信网页授权官方文档(scope 定义、接口地址、返回字段):https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html
- 获取全局接口调用凭证 access_token:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
- 公众号全局返回码说明(10003、40029、40163 等错误码):https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Global_Return_Code.html
- 接口权限与账号类型说明(网页授权域名的配置要求):https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Explanation_of_interface_permission_and_account_type.html
关键词:公众号网页授权、snsapi_base、snsapi_userinfo、OAuth2、ASP.NET Core、Redis 缓存、微信登录、redirect_uri