公众号服务器配置接入实录:token 验证、消息加解密与被动回复的三道坎
适用读者:负责微信公众号后端接入、正在写消息回调接口的后端工程师;对 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×tamp&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 幂等、微信回调