Security Considerations
Understanding replay attack prevention, time-bounded authorizations, and settlement monitoring in x402.
Overview
The x402 protocol implements multiple layers of security to protect against common attack vectors while maintaining a seamless user experience. These security measures are built on battle-tested Ethereum standards (EIP-712, EIP-3009) and blockchain cryptography.
Replay Attack Prevention
Replay attacks occur when an attacker intercepts a valid payment authorization and attempts to reuse it multiple times. x402 prevents this through three complementary mechanisms:
1. Nonce-Based Protection
Each payment authorization includes a unique random nonce (32-byte hex string):
const nonce = ethers.randomBytes(32);
// Example: 0x3456789012345678901234567890123456789012345678901234567890123456The USDC contract (following EIP-3009) tracks which nonces have been used on-chain. When a transferWithAuthorization is executed:
- The contract checks if the nonce has been used before
- If the nonce is new, the transfer proceeds and the nonce is marked as used
- If the nonce was already used, the transaction reverts with an error
This makes each authorization single-use. Even if an attacker intercepts the authorization, they cannot reuse it because the nonce is already consumed on-chain.
2. Time-Bounded Authorizations
Every authorization includes validAfter and validBefore timestamps (Unix time in seconds):
{
"validAfter": "1740672089", // January 7, 2025, 10:00:00 AM UTC
"validBefore": "1740672389" // January 7, 2025, 10:05:00 AM UTC (5 minutes later)
}The USDC contract enforces these timing constraints:
- If current time <
validAfter: Transaction reverts (not yet valid) - If current time ≥
validBefore: Transaction reverts (expired) - Only between these times: Transaction can execute
Best practices:
- Set short validity windows (5-10 minutes) for most payments
- For delayed/scheduled payments, set appropriate
validAfter - Never set
validBeforetoo far in the future (increases replay risk if nonce tracking fails)
Example timing strategy:
const now = Math.floor(Date.now() / 1000);
const authorization = {
validAfter: now.toString(), // Valid immediately
validBefore: (now + 300).toString(), // Expires in 5 minutes
// ... other fields
};3. Transaction Hash Uniqueness
Each blockchain transaction has a unique hash. Merchants should track received transaction hashes to ensure they don't fulfill the same payment twice:
// Server-side tracking
const processedTransactions = new Set();
function fulfillPayment(txHash, resource) {
if (processedTransactions.has(txHash)) {
throw new Error("Payment already fulfilled");
}
// Deliver resource to user
deliverResource(resource);
// Mark transaction as processed
processedTransactions.add(txHash);
}Why this matters: Even though blockchain nonces prevent double-spending, merchants should track which payments they've already fulfilled. This prevents edge cases where:
- A facilitator sends duplicate settlement notifications
- Network issues cause duplicate webhook deliveries
- Multiple facilitators process the same authorization (if incorrectly configured)
Signature Validation
All payment authorizations use EIP-712 typed structured data signatures, providing cryptographic proof that the user authorized the payment.
What EIP-712 Signatures Protect Against
- Forgery: Only the user's private key can create a valid signature
- Tampering: Changing any field invalidates the signature
- Phishing: The signature includes domain binding (verifyingContract address)
- Man-in-the-middle: The signature covers all payment details
Server-Side Signature Verification
Facilitators must validate signatures before submitting transactions:
import { verifyTypedData } from 'ethers';
function validatePaymentSignature(payload) {
const { signature, authorization } = payload.payload;
const { network } = payload;
// EIP-712 domain for USDC on Avalanche Fuji
const domain = {
name: "USD Coin",
version: "2",
chainId: 43113, // Avalanche Fuji
verifyingContract: "0x5425890298aed601595a70AB815c96711a31Bc65"
};
// EIP-712 type definition
const types = {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" }
]
};
// Recover signer address from signature
const recovered = verifyTypedData(
domain,
types,
authorization,
signature
);
// Verify the signer matches the 'from' address
if (recovered.toLowerCase() !== authorization.from.toLowerCase()) {
throw new Error("Invalid signature: signer does not match 'from' address");
}
return true;
}Key validation steps:
- Recover the signer address from the signature
- Verify it matches the
fromfield in the authorization - Check all other fields (amount, recipient, timing)
- Only submit to blockchain if everything validates
On-Chain Signature Verification
The USDC contract performs the same signature validation on-chain when transferWithAuthorization is called. This provides trustless verification—even if the facilitator is malicious, an invalid signature will cause the transaction to revert.
Authorization Timing Validation
Facilitators should validate timing constraints before submitting transactions to avoid wasting gas on expired authorizations:
function validateTiming(authorization) {
const now = Math.floor(Date.now() / 1000);
const validAfter = parseInt(authorization.validAfter);
const validBefore = parseInt(authorization.validBefore);
if (now < validAfter) {
throw new Error("Authorization not yet valid");
}
if (now >= validBefore) {
throw new Error("Authorization expired");
}
// Optional: Warn if expiring soon
if (now + 30 >= validBefore) {
console.warn("Authorization expires in less than 30 seconds");
}
return true;
}This prevents:
- Submitting transactions that will revert due to timing
- Wasting gas fees on failed transactions
- Poor user experience from delayed settlements
Settlement Monitoring
Facilitators monitor the blockchain in real-time to detect when payments are settled and to verify transaction success.
What Facilitators Monitor
After submitting a transferWithAuthorization transaction:
- Transaction Confirmation: Wait for the transaction to be included in a block
- Transaction Success: Verify the transaction status is
1(success), not0(failure) - Event Emissions: Check that the
Transferevent was emitted with correct parameters - Finality: On Avalanche, wait ~1-2 seconds for finality
Webhook Notifications
Most facilitators provide webhook notifications when settlements complete:
// Merchant server receives webhook
app.post('/webhooks/payment-settled', (req, res) => {
const { transaction, network, payer, amount, success } = req.body;
if (success) {
// Verify transaction on-chain (trustless)
const verified = await verifyTransactionOnChain(transaction, network);
if (verified) {
// Fulfill user's order
fulfillOrder(payer, amount);
}
}
res.status(200).send('OK');
});Best practice: Always verify webhook data by querying the blockchain directly. Don't trust the webhook alone—use it as a notification, then verify on-chain.
Independent Monitoring
Merchants can also monitor the blockchain directly without relying on facilitators:
import { ethers } from 'ethers';
// Monitor for Transfer events to your address
const provider = new ethers.JsonRpcProvider('https://api.avax-test.network/ext/bc/C/rpc');
const usdcContract = new ethers.Contract(usdcAddress, usdcAbi, provider);
// Listen for incoming payments
usdcContract.on('Transfer', (from, to, value, event) => {
if (to.toLowerCase() === myAddress.toLowerCase()) {
console.log(`Received ${value} USDC from ${from}`);
console.log(`Transaction: ${event.transactionHash}`);
// Fulfill payment
fulfillPayment(event.transactionHash);
}
});This provides complete independence from facilitators for settlement verification.
Amount Verification
Always verify that the payment amount meets your requirements:
function validatePaymentAmount(authorization, maxAmountRequired) {
const authorizedAmount = BigInt(authorization.value);
const requiredAmount = BigInt(maxAmountRequired);
if (authorizedAmount < requiredAmount) {
throw new Error(
`Insufficient payment: required ${requiredAmount}, got ${authorizedAmount}`
);
}
return true;
}Important: Amounts are in token base units (see Amount Specification for details).
Accept payments equal to or greater than maxAmountRequired to allow for user generosity (tips).
Network Verification
Verify that the payment is on the expected blockchain network:
function validateNetwork(payload, expectedNetwork) {
if (payload.network !== expectedNetwork) {
throw new Error(
`Wrong network: expected ${expectedNetwork}, got ${payload.network}`
);
}
// Also verify the chainId matches the network
const expectedChainId = getChainId(expectedNetwork);
// Signature domain includes chainId, so this is validated in signature verification
}This prevents:
- Payments on testnets being accepted as mainnet payments
- Cross-chain replay attacks
- Merchant configuration errors
Recipient Address Verification
Verify that payments are sent to the correct recipient address:
function validateRecipient(authorization, expectedPayTo) {
if (authorization.to.toLowerCase() !== expectedPayTo.toLowerCase()) {
throw new Error(
`Wrong recipient: expected ${expectedPayTo}, got ${authorization.to}`
);
}
}This is especially important when:
- Supporting multiple merchants on one facilitator
- Using different addresses for different services
- Accepting payments to multiple wallets
Token Contract Verification
Verify that the payment uses the expected token (asset):
function validateAsset(paymentRequest, expectedAsset) {
if (paymentRequest.asset.toLowerCase() !== expectedAsset.toLowerCase()) {
throw new Error(
`Wrong token: expected ${expectedAsset}, got ${paymentRequest.asset}`
);
}
}Why this matters: Attackers could try to pay with worthless tokens that share similar addresses. Always validate the exact contract address.
Example USDC addresses:
- Avalanche C-Chain:
0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E - Avalanche Fuji:
0x5425890298aed601595a70AB815c96711a31Bc65 - Base Sepolia:
0x036CbD53842c5426634e7929541eC2318f3dCF7e
Rate Limiting and Abuse Prevention
Implement rate limiting to prevent abuse:
// Simple rate limiting by address
const paymentsByAddress = new Map();
function checkRateLimit(from, maxPerHour = 10) {
const now = Date.now();
const oneHour = 60 * 60 * 1000;
if (!paymentsByAddress.has(from)) {
paymentsByAddress.set(from, []);
}
const payments = paymentsByAddress.get(from);
// Remove old payments outside the window
const recentPayments = payments.filter(time => now - time < oneHour);
if (recentPayments.length >= maxPerHour) {
throw new Error(`Rate limit exceeded: max ${maxPerHour} payments per hour`);
}
recentPayments.push(now);
paymentsByAddress.set(from, recentPayments);
}This prevents:
- Spam attacks on your API
- Nonce exhaustion attacks
- Resource abuse
Summary
x402 security relies on multiple defense layers:
- Nonce-based replay prevention: Each authorization is single-use
- Time-bounded authorizations: Payments expire after a set time window
- EIP-712 signatures: Cryptographic proof of user authorization
- On-chain validation: USDC contract verifies all constraints
- Transaction hash tracking: Merchants prevent duplicate fulfillment
- Amount, network, and asset verification: Validate all payment parameters
- Settlement monitoring: Real-time blockchain monitoring for finality
These mechanisms provide trustless, cryptographically secure payments without requiring users to trust facilitators or merchants. The blockchain enforces all security rules.
Additional Resources
Is this guide helpful?
