使用 Revolut webhook 时如何验证请求来源?
明确结论
验证 Revolut 支付 Webhook 的来源时,应使用配置 Webhook 时获得的签名密钥,结合请求头 Revolut-Request-Timestamp 和未经处理的原始请求体重新计算 HMAC-SHA256,再与 Revolut-Signature 比较。
不要只依赖来源 IP、User-Agent 或请求中的订单号。这些信息可能发生变化,也可能被伪造,无法替代签名验证。
验证流程
收到 Webhook 后,按以下顺序处理:
- 读取
Revolut-Request-Timestamp。 - 获取原始请求体,不要使用重新序列化后的 JSON。
- 拼接待签名内容:
{timestamp}.{raw_request_body}
- 使用 Webhook signing secret 计算 HMAC-SHA256。
- 在十六进制摘要前加上
v1=。 - 使用恒定时间比较方法,将结果与
Revolut-Signature中的签名比较。 - 检查时间戳是否在允许范围内,防止合法请求被重放。
- 签名验证通过后,再解析 JSON 并更新订单状态。
签名密钥应使用 Revolut 在创建或配置 Webhook 时返回或展示的 signing secret。它不是 API 密钥,也不应写入前端代码或日志。如果当前 Webhook 配置中没有签名密钥,需要根据实际使用的 Revolut Merchant API 版本检查配置或重新创建 Webhook,不能自行用某个 API 密钥代替。
Node.js / Express 示例
这里需要保留原始请求体。下面的路由使用 express.raw(),不要让全局 express.json() 提前处理该路径。
const express = require('express');
const crypto = require('crypto');
const app = express();
const webhookSecret = process.env.REVOLUT_WEBHOOK_SECRET;
const toleranceSeconds = 300;
function safeEqualHex(actualHex, expectedHex) {
if (!/^[a-f0-9]+$/i.test(actualHex)) {
return false;
}
const actual = Buffer.from(actualHex, 'hex');
const expected = Buffer.from(expectedHex, 'hex');
return (
actual.length === expected.length &&
crypto.timingSafeEqual(actual, expected)
);
}
function verifyRevolutWebhook(rawBody, timestamp, signatureHeader) {
if (!webhookSecret || !timestamp || !signatureHeader) {
return false;
}
const timestampSeconds = Number(timestamp);
if (!Number.isFinite(timestampSeconds)) {
return false;
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestampSeconds) > toleranceSeconds) {
return false;
}
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody
]);
const expectedDigest = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex');
// 兼容请求头中存在多个以逗号分隔的签名值
const signatures = signatureHeader
.split(',')
.map(value => value.trim())
.filter(value => value.startsWith('v1='))
.map(value => value.slice(3));
return signatures.some(signature =>
safeEqualHex(signature, expectedDigest)
);
}
app.post(
'/webhooks/revolut',
express.raw({ type: '*/*' }),
async (req, res) => {
const timestamp = req.get('Revolut-Request-Timestamp');
const signature = req.get('Revolut-Signature');
if (!verifyRevolutWebhook(req.body, timestamp, signature)) {
return res.status(401).send('Invalid webhook signature');
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).send('Invalid JSON');
}
// 先做幂等检查,再根据 event 更新订单状态
// await handleRevolutEvent(event);
return res.sendStatus(204);
}
);
app.listen(3000);
如果应用已经全局启用 JSON 中间件,应先注册 Webhook 路由:
app.post(
'/webhooks/revolut',
express.raw({ type: '*/*' }),
revolutWebhookHandler
);
app.use(express.json());
请求体一旦被解析,再通过 JSON.stringify() 生成,其中的空格、换行或字段格式就可能发生变化,导致签名始终无法匹配。
Python / Flask 示例
在 Flask 中,应先调用 request.get_data() 获取原始字节:
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request, Response
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["REVOLUT_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300
def verify_revolut_webhook(raw_body, timestamp, signature_header):
if not timestamp or not signature_header:
return False
try:
timestamp_seconds = int(timestamp)
except ValueError:
return False
if abs(int(time.time()) - timestamp_seconds) > TOLERANCE_SECONDS:
return False
signed_payload = timestamp.encode("utf-8") + b"." + raw_body
expected_digest = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
signed_payload,
hashlib.sha256,
).hexdigest()
signatures = [
item.strip()[3:]
for item in signature_header.split(",")
if item.strip().startswith("v1=")
]
return any(
hmac.compare_digest(signature, expected_digest)
for signature in signatures
)
@app.post("/webhooks/revolut")
def revolut_webhook():
raw_body = request.get_data(cache=True)
timestamp = request.headers.get("Revolut-Request-Timestamp")
signature = request.headers.get("Revolut-Signature")
if not verify_revolut_webhook(raw_body, timestamp, signature):
return Response("Invalid webhook signature", status=401)
try:
event = json.loads(raw_body)
except json.JSONDecodeError:
return Response("Invalid JSON", status=400)
# 在这里执行幂等检查并处理 event
return Response(status=204)
还需要注意什么
防止重放攻击
签名正确只能说明请求由持有密钥的一方生成,不能证明它是第一次到达。还需要采取以下措施:
- 限制
Revolut-Request-Timestamp与服务器时间的差值,例如不超过 5 分钟; - 记录已经处理过的事件 ID;
- 同一个事件重复回调时返回成功,但不要重复发货、退款或记账。
允许的时间窗口应根据 Revolut 当前文档和系统时钟误差确定,同时确保服务器时间保持同步。
不要只相信事件内容
即使签名验证通过,在执行发货、入账等重要操作前,仍应使用事件中的订单标识查询或核对本地订单,确认:
- 订单属于当前商户;
- 金额和币种符合预期;
- 状态转换合法;
- 该事件尚未处理。
必要时,可以再调用 Revolut API 查询订单的当前状态,但查询接口不能完全代替 Webhook 签名验证。
密钥管理
Webhook signing secret 应保存在服务端的环境变量或密钥管理系统中。不要:
- 提交到 Git 仓库;
- 返回给浏览器;
- 输出到应用日志;
- 与 API token 混用;
- 在签名失败日志中记录完整请求头和敏感支付数据。
如果签名密钥可能已经泄露,应轮换密钥或重建 Webhook。过渡期间,按照 Revolut 当前支持的方式处理新旧签名。
不要把 IP 白名单作为唯一验证手段
除非 Revolut 针对所使用的产品和环境明确发布并维护固定出口 IP 列表,否则不要用 IP 白名单验证身份。代理、网络架构或出口地址都可能调整。IP 限制可以作为附加防护,但签名验证仍是主要验证方式。
如果收到的请求中没有 Revolut-Signature 或 Revolut-Request-Timestamp,应先确认当前使用的 Revolut 产品、API 版本和 Webhook 创建方式。不同产品或历史接口可能采用不同机制。在没有对应签名规范的情况下,不能声称仅凭请求本身就能可靠证明其来源。
备注:内容仅供参考。
