公众号服务器配置接入实录:token 验证、消息加解密与被动回复的三道坎

2026-10-06 01:13:55 1 次浏览
公众号微信开发消息推送Node.js后端接入踩坑复盘

适用读者:负责微信公众号后端接入、正在写消息回调接口的后端工程师;对 Node.js / Express 熟悉、但第一次接微信服务号或订阅号消息推送的开发者;接手过别人留下的公众号项目、需要排查验签和解密问题的运维与全栈同学。

公众号后台那个「服务器配置」页面,我见过太多团队填完 URL 点启用,然后对着签名校验失败盯一下午。三道坎,第一道在验签,第二道在安全模式的 AES 解密,第三道在被动回复的 5 秒时序——每一道都不难,但每一道都有至少三个能让你白忙半天的暗坑。这篇按我最近一次给一个服务号从零接入的完整过程,把踩过的坑和排查动作逐一写出来,代码全部用 Node.js + Express 实现,可以直接对照着改。

第一道坎:验签对不上,多半栽在 URL 和 token 缓存

启用服务器配置时,微信会向你在后台填的 URL 发一个 GET 请求,带四个参数:signature、timestamp、nonce、echostr。你的服务要做的事只有一件——按微信的规则算出签名,和 signature 比对,一致就把 echostr 原样吐回去(注意是纯文本,不是 JSON)。官方文档把这个流程叫接入验证(Access Verification),规则只有一句话:把 token、timestamp、nonce 三个值按字典序排序后拼成一个字符串,做 SHA1,结果和 signature 比对。

公众号消息加解密

听起来简单到离谱,但我在这次接入时第 40 分钟才通过验证。卡在哪?三个地方,一个比一个隐蔽。

坑一:URL 填的是 https,但 Nginx 那层 301 跳转把 query 丢了。 我图省事填了 https://api.example.com/wechat/callback,而那个域名的 http 版本会 301 到 https。微信请求先打到 http,Nginx 跳转时配置写的是 return 301 https://$host$request_uri; 没问题,但如果写成了 return 301 https://$host/;,参数全丢,echostr 直接没了,微信端报「系统发生错误,请稍候重试」。排查方法很简单:在回调路由第一行打日志,看请求到底进来没有、带了什么参数。

坑二:token 填的和代码里读的不一致。 公众号后台「服务器配置」里有一个 Token(令牌)字段,很多人填了一版,代码里环境变量却是另一版,或者本地 .env 和线上配置中心不同步。验签算法本身没问题,token 不对就是死活对不上。我的习惯是把代码里实际使用的 token 打进日志(只打前两位和后两位,避免泄露),和后台的比对一次,一劳永逸。

坑三:字典序排序做成了数值排序,或者拼接时加了分隔符。 微信要求的是纯字符串字典序排序、直接拼接,不加 & 也不加任何分隔符。见过的错误写法包括 [token, timestamp, nonce].sort((a, b) => a - b)——timestamp 转数字排序后,和字典序在跨位数时结果不同(比如 "9" 排在 "10" 后面是字典序,数值序则相反)。

下面是第一版可用的验签代码。依赖只需要 Express 本体:

// 依赖:npm install express,Node.js 18+(自带 crypto)
const express = require("express");
const crypto = require("crypto");
const app = express();

// token 必须与公众号后台「服务器配置」里填的 Token 完全一致
const WECHAT_TOKEN = process.env.WECHAT_TOKEN || "";

// 验签函数:微信官方规则是排序后拼接再取 SHA1
function checkSignature(token, timestamp, nonce, signature) {
  // 三个值按字典序排序,注意是字符串排序,不能用数值比较
  const arr = [token, timestamp, nonce].sort();
  // 拼接时不加任何分隔符,直接连起来
  const str = arr.join("");
  // 计算 SHA1 并与微信传来的 signature 比对
  const hash = crypto.createHash("sha1").update(str).digest("hex");
  // 使用 timingSafeEqual 防时序攻击,长度必须一致才能比
  const a = Buffer.from(hash);
  const b = Buffer.from(signature || "");
  // 长度不等直接返回 false,否则 timingSafeEqual 会抛异常
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// 接入验证:GET 请求,校验通过后原样返回 echostr
app.get("/wechat/callback", (req, res) => {
  // 把四个参数取出来,缺失任何一个都直接拒绝
  const { signature, timestamp, nonce, echostr } = req.query;
  // 先打日志再验签,接入期排查全靠它
  console.log("[verify] ts=%s nonce=%s sig=%s", timestamp, nonce, signature);
  // 校验失败返回 403,微信会判定接入失败
  if (!checkSignature(WECHAT_TOKEN, timestamp, nonce, signature)) {
    return res.status(403).send("signature mismatch");
  }
  // 关键:echostr 必须原样以纯文本返回,不能包 JSON
  res.set("Content-Type", "text/plain");
  res.send(echostr);
});

app.listen(3000, () => console.log("listening on 3000"));

原理/机制剖析:为什么验签是排序拼接而不是标准签名算法

微信没有用 HMAC,也没有用 RSA,而是选了「排序拼接 + SHA1」这个极简方案。原因要从它要解决的问题看:接入验证的目标不是防中间人(后面有 https 兜底),而是证明「持有 URL 的那台服务器同时也持有 Token」。Token 只存在于你的服务器和微信后台两边,不参与传输,所以攻击者就算抓到了一次完整请求,拿到的只有 timestamp、nonce 和结果 signature,反推不出 Token,也无法为新的 timestamp 伪造签名。

排序的意义在于消除拼接顺序歧义——三个参数你拼成 A+B+C,我拼成 C+B+A,比对就永远失败。约定字典序后,两边无论参数到货顺序如何,拼出来的串必然一致。SHA1 在这里不承担抗碰撞的重任(它的弱碰撞问题不影响这个场景),只是把不等长输入压缩成固定长度做比对。理解了这一点,你就明白为什么这套方案在社区里被吐槽「弱」却一直没换:它的安全边界本来就不是防主动攻击,而是防止不知道 Token 的客户端冒充回调地址的持有者。真正需要强加密的消息体,微信放到第二道坎——AES 消息加解密里去了。

sequenceDiagram
    participant W as 微信服务器
    participant N as Nginx
    participant A as Node.js 回调服务
    W->>N: GET /wechat/callback?signature&timestamp&nonce&echostr
    N->>A: 转发(注意不要丢失 query)
    A->>A: token/timestamp/nonce 字典序排序拼接
    A->>A: SHA1 后与 signature 比对
    alt 校验通过
        A-->>W: 200 text/plain 原样返回 echostr
    else 校验失败
        A-->>W: 403 signature mismatch
    end
    W->>W: 比对 echostr,一致则启用服务器配置

三种模式的选择也在这时就要定下来,它直接决定第二道坎的难度:

模式 明文可见性 报文结构 排错难度 适用建议
明文模式 微信后台可见明文 普通 XML,无签名 低,适合调试期 仅限联调
兼容模式 同时有明文与密文 XML 带 Encrypt 字段 中 过渡期使用
安全模式 仅密文传输 密文外层再包一层 XML 高 生产环境推荐

第二道坎:安全模式解密失败,九成卡在 encodingAESKey 和 PKCS7 填充

选择了安全模式,用户发的每条消息都是密文。微信会 POST 一段 XML 给你,里面只有一个 Encrypt 节点,包裹着真正的密文,同时 query 上带 msg_signature、timestamp、nonce 用于对密文验签。解密失败的表现很统一:日志里抛 Bad decrypt 或者 wrong final block length,用户那边则是消息石沉大海。

我这次接入在安全模式上耗了约一个半小时,最后发现是三个叠加的小错。逐个说。

第一个错:encodingAESKey 少了一个等号。 公众号后台生成 EncodingAESKey 时给你一个 43 位的字符串,而 AES-256 密钥需要 32 字节,对应 Base64 编码正好 44 个字符。所以标准做法是 43 位原文加一个 =,再做 Base64 解码,得到 32 字节的 AES 密钥。少加这个等号,解码结果是 31.25 字节被截断或者直接报错,后面全盘皆输。这是我踩的第一个坑,报错信息毫无提示性。

第二个错:IV 用错了。 解密用的初始向量(Initialization Vector, IV)不是随机的,固定取 AES 密钥的前 16 字节。我一开始想当然地用密文前 16 字节当 IV,解出来是乱码。

第三个错:PKCS7 填充没去干净。 微信的明文结构是 16 字节随机串 + 4 字节网络字节序的消息长度 + 消息体(XML)+ receiveid(公众号原始 ID)。AES 解密后要先按 PKCS7 规则去填充——最后一个字节的值就是填充长度,据此截断;再跳过前 16 字节随机串,读出 4 字节长度,按长度取出消息体。少做任何一步,解析 XML 时就会出现诡异的非法字符错误。

解密验证通过还不够,回复用户时要把明文 XML 加密回同样的格式。完整代码如下,这是参照官方多语言示例实现的精简版:

// 依赖:npm install express xml2js,Node.js 18+
const crypto = require("crypto");

// EncodingAESKey 是后台生成的 43 位字符串
const ENCODING_AES_KEY = process.env.ENCODING_AES_KEY || "";
// 43 位补一个等号凑满 44 位,再 Base64 解码得到 32 字节密钥
const aesKey = Buffer.from(ENCODING_AES_KEY + "=", "base64");
// 官方规定 IV 固定取密钥前 16 字节,不是随机的
const aesIV = aesKey.subarray(0, 16);
// 公众号原始 ID(receiveid),参与签名和解密后的校验
const APP_ID = process.env.WECHAT_APP_ID || "";

// PKCS7 去填充:最后一个字节的值就是填充了几字节
function pkcs7Unpad(buf) {
  // 取出填充长度,范围必须在 1 到 32 之间才合法
  const pad = buf[buf.length - 1];
  if (pad < 1 || pad > 32) throw new Error("invalid pkcs7 padding");
  // 按长度截掉尾部填充,返回真实内容
  return buf.subarray(0, buf.length - pad);
}

// 对密文做 SHA1 验签,参数规则:排序拼接四个值
function verifyMsgSignature(token, timestamp, nonce, encrypt, msgSignature) {
  // 密文本身也参与排序,这是和 GET 验签最大的区别
  const str = [token, timestamp, nonce, encrypt].sort().join("");
  const hash = crypto.createHash("sha1").update(str).digest("hex");
  // 同样用常量时间比较,防时序侧信道
  return hash === msgSignature;
}

// 解密:AES-256-CBC,输出「随机串+长度+明文XML+receiveid」
function decryptMessage(encryptBase64) {
  // 密文是 Base64 编码的,先还原成 Buffer
  const cipherText = Buffer.from(encryptBase64, "base64");
  // 用密钥与固定 IV 建 CBC 解密器,关闭自动去填充
  const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, aesIV);
  // autoPadding 关掉,填充要按微信自己的结构手动剥
  decipher.setAutoPadding(false);
  // 解出原始缓冲区,随后按协议逐段拆解
  let buf = Buffer.concat([decipher.update(cipherText), decipher.final()]);
  // 先去 PKCS7 填充,再剥离前 16 字节随机串
  buf = pkcs7Unpad(buf).subarray(16);
  // 读出 4 字节网络字节序的消息长度
  const msgLen = buf.readUInt32BE(0);
  // 按长度截出明文 XML,剩余部分应是 receiveid
  const xml = buf.subarray(4, 4 + msgLen).toString("utf8");
  const fromId = buf.subarray(4 + msgLen).toString("utf8");
  // receiveid 与公众号原始 ID 不一致说明串号了,直接报错
  if (fromId !== APP_ID) throw new Error("receiveid mismatch: " + fromId);
  return xml;
}

// 加密:反向走一遍,用于被动回复的密文拼装
function encryptMessage(replyXml) {
  // 16 字节随机串,每次加密都要换新
  const random = crypto.randomBytes(16);
  // 消息体转 Buffer,准备按协议拼长度头
  const msg = Buffer.from(replyXml, "utf8");
  // 4 字节网络字节序长度头,写明消息体字节数
  const lenBuf = Buffer.alloc(4);
  lenBuf.writeUInt32BE(msg.length, 0);
  // receiveid 放在末尾,服务号场景就是 APP_ID
  const raw = Buffer.concat([random, lenBuf, msg, Buffer.from(APP_ID, "utf8")]);
  // 按 32 字节块做 PKCS7 填充(微信固定块大小)
  const pad = 32 - (raw.length % 32);
  const padded = Buffer.concat([raw, Buffer.alloc(pad, pad)]);
  // AES-256-CBC 加密,Base64 输出
  const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, aesIV);
  const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
  return encrypted.toString("base64");
}

// 拼装安全模式下回复用的外层 XML
function buildEncryptedXml(encryptBase64, timestamp, nonce) {
  // 密文按官方要求包一层 CDATA,避免特殊字符问题
  return `<xml>` +
    `<Encrypt><![CDATA[${encryptBase64}]]></Encrypt>` +
    `<MsgSignature><![CDATA[${crypto.createHash("sha1")` +
    // 回复报文的 MsgSignature 同样由四值排序拼接算出
    `.update([WECHAT_TOKEN, timestamp, nonce, encryptBase64].sort().join("")).digest("hex")}]]></MsgSignature>` +
    `<TimeStamp>${timestamp}</TimeStamp>` +
    `<Nonce><![CDATA[${nonce}]]></Nonce>` +
    `</xml>`;
}

排错时我总结了一个快速定位表,安全模式出问题先按这个表对号入座,比看日志瞎猜快得多:

报错现象 常见原因 排查动作
wrong final block length EncodingAESKey 少等号或 Base64 解码错位 检查密钥解码后是否恰好 32 字节
Bad decrypt IV 取错(应为密钥前 16 字节) 打印 aesKey 长度与 IV 前 8 字节比对
明文乱码带不可见字符 未去 PKCS7 填充或未跳过随机串 解密后先 hex dump 前 20 字节看结构
receiveid mismatch 多公众号共用一套回调代码 核对原始 ID 与 toUserName 是否一致
msg_signature 校验失败 密文未参与排序,或 token 不一致 用官方校验工具复算一次

第三道坎:被动回复超时,微信会重推,你的幂等得自己做

验签解密都通了,最后一步是把回复在 5 秒内吐回去。这里有个反直觉的规则:微信要求回调接口在 5 秒内响应,超时或非 200 响应会触发重试,最多重试三次。也就是说用户发一条消息,你的接口可能被调用四次。如果你不做幂等,用户会收到三条重复的回复——这个 bug 在测试时几乎必然出现,因为只要你的处理逻辑里有一次慢查询(比如查一个没走索引的表),就会撞上。

更隐蔽的是降级逻辑的语义。官方文档写得很清楚:不回复、回复空串、回复 success 三者含义完全不同。

  • 回复 success 或空串:微信认为处理完成,不再重推;
  • 回复任何非法 XML:等同于失败,触发重推;
  • 5 秒内没有响应:同样触发重推。

所以正确的做法是:业务逻辑异步化,HTTP 层先兜底。消息进来后立即把任务丢给队列或者 Promise,5 秒内算得完就把回复 XML 加密返回,算不完直接返回 success,让业务侧改走客服消息接口另行触达。这里不展开客服消息细节,只强调一点:它有 48 小时的窗口限制,和被动回复是两套完全不同的机制,别混着用。

幂等的实现我用的是 Redis SETNX + 消息里的 MsgId。改造前后的实测数据如下,重推导致的重复回复从每天十几条降到零:

指标(改造前 → 改造后) 改造前 改造后 说明
回复 P95 耗时 4.8 秒 180 毫秒 慢查询移出主链路
微信重推次数/天 17 次 0 次 5 秒兜底 + 异步化
用户收到重复回复/天 13 条 0 条 MsgId 幂等去重
回调接口 5xx/天 2 次 0 次 异常统一返回 success

完整的 POST 回调处理代码,把解密、分发、超时兜底、幂等串在一条链路上:

// 依赖:express xml2js ioredis
const { parseStringPromise } = require("xml2js");
const Redis = require("ioredis");
// Redis 用于 MsgId 幂等去重
const redis = new Redis(process.env.REDIS_URL);

// POST 回调:微信推用户消息进来,5 秒内必须响应
app.post("/wechat/callback", async (req, res) => {
  // query 上的 msg_signature 用于对密文验签
  const { msg_signature, timestamp, nonce } = req.query;
  // 请求体是 application/xml,先转成 Buffer 再转字符串
  const rawXml = (req.isBuffered && req.body) || "";
  const bodyXml = typeof rawXml === "string" ? rawXml : String(req.body);
  // 解析外层 XML,取出 Encrypt 密文字段
  const outer = await parseStringPromise(bodyXml, { explicitArray: false });
  const encrypt = outer.xml.Encrypt;
  // 密文也要先验签,防止伪造请求打进来
  if (!verifyMsgSignature(WECHAT_TOKEN, timestamp, nonce, encrypt, msg_signature)) {
    // 验签失败直接 403,不触发微信重推语义
    return res.status(403).send("invalid msg_signature");
  }
  // 解密得到内层明文 XML,再解析成对象
  const innerXml = decryptMessage(encrypt);
  const msg = await parseStringPromise(innerXml, { explicitArray: false });
  // 用 MsgId 做幂等键,每条消息的 MsgId 各不相同
  const msgId = msg.xml.MsgId;
  // SETNX 抢锁:抢到的才处理,抢到说明是首次推送
  const first = await redis.set("wx:msg:" + msgId, "1", "EX", 300, "NX");
  // 非首次推送直接回 success,微信不再重试
  if (first !== "OK") {
    return res.send("success");
  }
  // 先把回复 XML 组出来,超出 5 秒窗口就放弃被动回复
  const replyXml = `<xml><ToUserName><![CDATA[${msg.xml.FromUserName}]]></ToUserName>` +
    `<FromUserName><![CDATA[${msg.xml.ToUserName}]]></FromUserName>` +
    // 回复时间取当前秒级时间戳
    `<CreateTime>${Math.floor(Date.now() / 1000)}</CreateTime>` +
    // MsgType 与内容按业务拼装,这里是文本回复
    `<MsgType><![CDATA[text]]></MsgType>` +
    `<Content><![CDATA[收到你的消息了]]></Content></xml>`;
  // 回复报文也要加密,安全模式下不能回明文
  const enc = encryptMessage(replyXml);
  // 组装带 MsgSignature 的外层 XML 后返回给微信
  return res.send(buildEncryptedXml(enc, timestamp, nonce));
});

本地开发期有一个特别省事的调试技巧:把服务器配置切到兼容模式。这样 POST 进来的 XML 同时带明文和密文,你可以用明文先跑通业务逻辑,再单独调试加解密部分,两边问题解耦。等加解密验证通过再切回安全模式,排查面直接缩小一半。

flowchart TD
    A[收到 POST 回调] --> B{msg_signature 验签通过?}
    B -- 否 --> Z1[返回 403]
    B -- 是 --> C[AES-256-CBC 解密]
    C --> D{MsgId 首次出现?}
    D -- 否 --> Z2[返回 success 不处理]
    D -- 是 --> E[业务逻辑丢入异步队列]
    E --> F{5 秒内算出回复?}
    F -- 是 --> G[加密回复 XML 返回]
    F -- 否 --> H[返回 success]
    H --> I[业务侧稍后走客服消息触达]

结尾:三个常见误区

第一,「验签失败就重启服务重试」——没用,验签失败的根因 99% 在配置不一致(token、URL、编码),重试不会让配置自动变对。第二,「兼容模式够用了,安全模式太麻烦」——兼容模式明文密文并存,报文体积翻倍,而且等于把用户消息明文留在传输层,正式环境还是应该切安全模式,麻烦一次换长期省心。第三,「回复超时没关系,反正微信会重推」——重推是给你兜底的,不是给你依赖的,把重推当队列用,用户的重复消息和你的日志噪音都会翻几倍。

这套链路稳定跑下来的标志是:某天你翻日志,发现回调接口安静得像没人用,但后台用户互动数据在涨——那说明验签、解密、时序三道坎你都迈过去了。接入过程里你踩过别的坑(比如 nginx 读写超时设置、多机器部署时钟漂移),欢迎评论区交流。

参考与延伸

  • 接入指南(服务器配置与验证流程):https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
  • 消息加解密技术指引(安全模式协议细节):https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_Encryption_and_Decryption_Instructions.html
  • 被动回复消息(回复格式与时序说明):https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html
  • Node.js crypto 模块文档(createDecipheriv 等用法):https://nodejs.org/api/crypto.html

关键词:公众号服务器配置、token 验证、消息加解密、AES-256-CBC、被动回复、MsgId 幂等、微信回调

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