公众号网页授权怎么做:snsapi_base 与 snsapi_userinfo 踩坑实录

2026-10-09 01:16:48 3 次浏览
公众号微信开发OAuth2ASP.NET Core网页授权

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

从一个 10003 报错说起

先说那次翻车的现场。活动页 m.xxx-h5.com 上的「立即参与」按钮,跳转链接拼的是:

公众号网页授权怎么做:snsapi_ba主题图

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 粒度的全局状态,跟你的服务端是不是同一台机器无关。这就带来一个经典并发问题:

  1. 用户点按钮,前端发了回调请求 A,网络抖动,A 还没返回;
  2. 前端超时重试(或者用户不耐烦又点了一下),发出请求 B;
  3. A 和 B 几乎同时到达服务端,都拿着同一个 code 去微信换 token;
  4. 先到的成功,后到的报 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 的规则容易记岔,这里把边界说清楚:

  1. 有效期 30 天,期间可以用它反复换新的 web access_token,refresh_token 本身一般保持不变;
  2. 用户在微信里取消授权或清空数据后,refresh_token 失效,只能重新走授权链接;
  3. snsapi_base 授权只拿 openid,没有用户信息可刷新,刷新动作对它没有意义——场景上就是拿 openid 查你自己的数据库;
  4. 网页授权 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

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。