x402 协议在生产中容易出现签名重放攻击:同一签名被 Agent 捕获后可永久免费调用;需在账本中记录唯一支付标识并检测重复引用。
支付签名本质上就是一串字节。如果你的验证器接受同一签名两次,那截获到的支付就成了一张永久免费通行证。
问题是怎么发生的:Agent 为 /api/report 支付一次,截获成功的支付载荷,之后每次调用都重放它。你的验证器每次都返回"有效"——因为它确实有效,只是只有效一次。
如何发现:你的账本应该记录每笔支付的唯一标识。查询它:如果同一支付引用出现两次,说明正在被重放攻击。
修复方案:每条支付要求都带有短期过期时间(我们用 5 分钟)和每次请求的 nonce。验证器拒绝过期或已见过的 nonce:
// Payment requirements must be single-use and short-lived.
// Without BOTH of these, a captured signature is a permanent free pass.
paidRoute({
price: '0.25',
payTo: '0xYourWallet',
network: 'base',
asset: 'USDC',
verifier, // must check nonce + expiry, not just signature validity
ledger, // append-only record of every verified payment
})
调试步骤:当请求被判定为重放而拒绝时,记录 nonce 及其首次出现的时间戳。如果你在几秒内看到同一 nonce 来自不同 IP,那是攻击,不是 bug。如果来自同一 agent 重试,说明你的 402 重新挑战可能丢失了新的 nonce——检查每次 402 是否都携带了新的 nonce。
本地测试时,基于 HMAC 的验证器就够了:你持有密钥,你签名,你验证。但它也是一把上了膛的枪,直指你的生产 API。
问题是怎么发生的:开发验证器信任持有密钥的任何人。如果它被发布出去,任何找到(或猜到)开发路径的人都可以免费调用。更糟糕的是,它看起来一切正常——请求验证通过,账本有记录,但收入是零。
如何发现:这种问题是静默的,这正是它危险的地方。检查是结构性的,不是行为性的:在启动时断言验证器类型,拒绝用错误的验证器启动。
const { createDevVerifier, createFacilitatorVerifier } = require('./x402-core');
// Local only. Never ship this.
const devVerifier = createDevVerifier({ secret: process.env.X402_DEV_SECRET });
// Production: verification goes through a real x402 facilitator
// (self-hosted x402.rs, thirdweb, Coinbase CDP — same pattern).
const verifier = createFacilitatorVerifier({ verifyUrl: 'https://your-facilitator.example/verify' });
生产就绪检查:NODE_ENV=production + 开发验证器 = 启动时崩溃,而不是仅输出一条警告。我是认真的——一行日志会被忽略,但一次崩溃会被修复。
你的支付要求 5 分钟后过期。Agent 的时钟、中间人的时钟、你的服务器时钟相差了 90 秒。有效支付在过期边界附近开始被拒绝,失败看起来像是随机的。
如何发现:每次过期拒绝时,记录三个时间戳:要求发出时间、要求过期时间、你服务器的当前时间。如果拒绝集中在过期后约 2 分钟内,那是时钟偏移,不是欺诈。
修复方案:保持较紧的过期时间(5 分钟对重放保护是合适的)但在每次拒绝时记录余量,使偏移可见。同时用 NTP 同步你的服务器——这是常识,但经常被忽略。
你的验证器 POST 到中间人来检查链上状态。中间人超时了。你的 API 该怎么做?
问题是怎么发生的:verify 调用没有超时 → 请求会挂起直到客户端放弃。或者更糟,有人写了个 catch 在错误时返回 "verified: true"——恭喜,你的 API 现在在中间人打个嗝时就免费了。
修复方案:短超时、关闭失败、记录到账本。
调试步骤:如果付费用户报告间歇性 502,在怪你的代码之前先检查中间人的延迟百分位数。我们把每次验证尝试及其结果都记入账本,就是为了排查这个问题。
Agent 支付了 0.249 USDC,而你的路由要求 0.25。或者它在 Base 上支付而你期望的是主网。或者 USDC 与某个桥接变体(具有不同合约地址)搞混了。
问题是怎么发生的:精确匹配验证会拒绝它,Agent 用同样差点就对的支付重试,双方都在消耗配额。从 Agent 的日志看你的 API 坏了;从你的日志看是少付了。
如何发现:每次支付不匹配时,把期望值和实际值作为结构化字段记录——金额、资产合约、网络——而不是只写"验证失败"。不匹配的模式能告诉你这是四舍五入 bug(他们的)、配置 bug(你的)还是资产混淆(双方的)。
生产就绪检查:在启动时验证 payTo 是期望网络上的校验和地址,且资产合约匹配。配置错误的接收地址不会大声报错——资金只是流向了一个错误的地方。
/.well-known/x402 通告你的价格,让 Agent 无需猜测就能发现接口。这是与 paidRoute 强制执行不同的代码路径。
问题是怎么发生的:你在路由配置里改了个价格但忘了清单(或反之)。Agent 按清单价格支付,还是收到了 402,于是认为你的 API 坏了。这是"测试时正常"最常见的失败模式,因为测试通常直接请求路由,根本不读清单。
如何发现:做一个一致性测试。获取你自己的清单,然后对每个列出的端点,断言强制执行的价格与之一致:
curl -s http://localhost:3402/.well-known/x402 | jq '.endpoints'
# compare against the price: values in your paidRoute() configs — by hand or in CI
生产就绪检查:把它做成测试,而不是手动步骤。九个端到端测试胜过一次仔细的部署。
最偷懒的 x402 实现返回 402 时不带任何 body,也不带 PAYMENT-REQUIRED 头。从技术上讲是合规的。实际上毫无用处——Agent 无法自助完成支付,所以它要么放弃,要么提交工单。你建了个收费口却没有收银员。
期望的响应:每次 402 必须携带机器可读的要求——价格、资产、网络、接收方、过期时间、nonce。如果你的 402 没告诉 Agent 怎么付钱,你就没有实现 x402,你只是实现了一扇门。
在任何 x402 端点接收真实流量之前:
以上所有都可以手动实现——检查清单就是整体架构,一个细心的开发者花一两周就能构建。当它开始变得重复的时候,是在第二个端点、第十个端点的时候:验证器接线、nonce 记账、账本追加、清单路由和一致性测试每次都一模一样,而每次复制都是新的机会让你犯下第 2 条或第 6 条的错误。
这就是我把 x402 Paid API Starter Kit 打包出来的原因:协议核心、Express 中间件、清单路由、一个可运行的演示(一个免费路由、两个收费路由、账本查看器),以及 9 个端到端测试,包括上面的一致性和重放检查。一个依赖(express),Node 18+。
但检查清单本身是独立的。无论你用什么构建,都可以跑一遍——包括手写的实现——你就能无故障模式地发布 x402。
Payload 为开发者构建小巧精悍的工具——MCP 变现、API 计量、可靠性套件。payloadhq.github.io