如何在Node.js中实现微服务间带签名的Webhook请求收发
Webhook 签名可选方案
常用的方案分为两类,可根据场景选择:
- 对称加密HMAC方案:两端共享同一个密钥,发送方用密钥生成签名,接收方用相同密钥生成签名比对。实现简单、性能高,适合内部微服务互信场景,缺点是密钥泄露后会完全失效。
- 非对称加密RSA/ECDSA方案:发送方持有私钥签名,接收方持有公钥验签,不需要共享敏感密钥,安全性更高,适合跨机构/公开webhook场景,性能略低于HMAC。
HMAC-SHA256 实现示例(推荐内部微服务使用)
Service X(发送方)签名逻辑
- 两端提前约定共享密钥,存储在环境变量中,禁止硬编码
- 拼接时间戳、请求方法、请求路径、原始请求体为待签名字符串
- 用密钥生成HMAC-SHA256签名,和时间戳一起放入请求头发送
代码示例:
const crypto = require('crypto'); const axios = require('axios'); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; const SERVICE_Y_ENDPOINT = 'https://service-y.com/order/123456'; async function sendOrderWebhook(payload) { const timestamp = Math.floor(Date.now() / 1000).toString(); const rawPayload = JSON.stringify(payload); // 待签名字符串格式要和接收端完全一致 const signContent = `${timestamp}\nPOST\n/order/123456\n${rawPayload}`; const signature = crypto .createHmac('sha256', WEBHOOK_SECRET) .update(signContent) .digest('hex'); await axios.post(SERVICE_Y_ENDPOINT, payload, { headers: { 'Content-Type': 'application/json', 'X-Webhook-Timestamp': timestamp, 'X-Webhook-Signature': `sha256=${signature}` } }); } // 调用示例 sendOrderWebhook({ orderId: '123456', status: 'paid', amount: 9900 });
Service Y(接收方)验签逻辑
- 先获取请求原始body,不能用直接解析后的body,避免内容被修改导致签名不匹配
- 校验时间戳和当前时间差不超过5分钟,防止重放攻击
- 用相同逻辑生成签名,和请求头的签名做安全比对
代码示例(基于Express):
const crypto = require('crypto'); const express = require('express'); const app = express(); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; const MAX_TIME_DIFF = 5 * 60; // 允许5分钟内的请求 // 保留原始请求body app.use(express.json({ verify: (req, _, buf) => req.rawBody = buf.toString() })); app.post('/order/:orderId', (req, res) => { const timestamp = req.headers['x-webhook-timestamp']; const signHeader = req.headers['x-webhook-signature']; // 缺少必要参数直接拒绝 if (!timestamp || !signHeader) return res.status(401).send('Missing signature headers'); // 防重放校验 if (Math.abs(Date.now()/1000 - Number(timestamp)) > MAX_TIME_DIFF) return res.status(401).send('Request expired'); const [alg, signature] = signHeader.split('='); if (alg !== 'sha256' || !signature) return res.status(401).send('Invalid signature format'); // 生成预期签名 const signContent = `${timestamp}\nPOST\n${req.path}\n${req.rawBody}`; const expectedSign = crypto.createHmac('sha256', WEBHOOK_SECRET).update(signContent).digest('hex'); // 安全比对,防止时序攻击 const signBuf = Buffer.from(signature, 'hex'); const expectedBuf = Buffer.from(expectedSign, 'hex'); if (signBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(signBuf, expectedBuf)) { return res.status(401).send('Invalid signature'); } // 校验通过,处理业务逻辑 res.status(200).send('OK'); }); app.listen(3000, () => console.log('Service Y running on port 3000'));
RSA-SHA256 非对称签名实现示例
Service X(发送方)签名代码
需提前生成RSA密钥对,私钥仅保存在Service X侧:
const crypto = require('crypto'); const axios = require('axios'); const fs = require('fs'); // 读取私钥,可从密钥管理服务获取 const PRIVATE_KEY = fs.readFileSync('./x_private.pem', 'utf8'); const SERVICE_Y_ENDPOINT = 'https://service-y.com/order/123456'; async function sendOrderWebhook(payload) { const timestamp = Math.floor(Date.now() / 1000).toString(); const rawPayload = JSON.stringify(payload); const signContent = `${timestamp}\nPOST\n/order/123456\n${rawPayload}`; const signature = crypto .sign('sha256', Buffer.from(signContent), PRIVATE_KEY) .toString('base64'); await axios.post(SERVICE_Y_ENDPOINT, payload, { headers: { 'Content-Type': 'application/json', 'X-Webhook-Timestamp': timestamp, 'X-Webhook-Signature': `rsa-sha256=${signature}` } }); }
Service Y(接收方)验签代码
仅需持有Service X的公钥即可完成验签:
const crypto = require('crypto'); const express = require('express'); const fs = require('fs'); const app = express(); const PUBLIC_KEY = fs.readFileSync('./x_public.pem', 'utf8'); const MAX_TIME_DIFF = 5 * 60; app.use(express.json({ verify: (req, _, buf) => req.rawBody = buf.toString() })); app.post('/order/:orderId', (req, res) => { const timestamp = req.headers['x-webhook-timestamp']; const signHeader = req.headers['x-webhook-signature']; if (!timestamp || !signHeader) return res.status(401).send('Missing signature headers'); if (Math.abs(Date.now()/1000 - Number(timestamp)) > MAX_TIME_DIFF) return res.status(401).send('Request expired'); const [alg, signature] = signHeader.split('='); if (alg !== 'rsa-sha256' || !signature) return res.status(401).send('Invalid signature format'); const signContent = `${timestamp}\nPOST\n${req.path}\n${req.rawBody}`; const isVerifyPass = crypto.verify( 'sha256', Buffer.from(signContent), PUBLIC_KEY, Buffer.from(signature, 'base64') ); if (!isVerifyPass) return res.status(401).send('Invalid signature'); res.status(200).send('OK'); }); app.listen(3000);
注意事项
- 所有密钥必须存储在环境变量、密钥管理服务中,禁止硬编码到代码仓库
- 签名比对必须使用
crypto.timingSafeEqual(HMAC场景),禁止使用普通字符串全等操作,避免时序攻击 - 必须添加时间戳校验,避免攻击者捕获历史请求重放攻击
- 待签名内容需包含请求核心信息(方法、路径、请求体、时间戳),避免攻击者篡改部分内容绕过校验
内容的提问来源于stack exchange,提问作者Farooq Hanif
相关产品推荐
相关产品推荐

