x402 Server-Side Security: Replay Attacks, Verification, and What to Log
Client-side x402 security gets most of the attention. Server-side is where the real edge cases are: verify before settle, duplicate payment detection, idempotency keys, and building an audit trail that holds up.
Most x402 security writing focuses on the client side: don't overpay, validate the payTo address, set spending limits. The server side gets less attention, and that's where the subtle bugs tend to hide.
This article covers what the server needs to get right.
The Verify → Settle Sequence
x402 has a two-phase settlement model. When a client sends a paid request, the server has two responsibilities:
- Verify — confirm the payment payload is valid before doing work
- Settle — submit the payment on-chain after the work is done
The critical rule: verify first, settle after.
If you settle before doing the work, you've taken the user's money and haven't committed to returning anything yet. If your handler throws after settlement, the caller paid for nothing. If you do the work before verifying, you've served the content to someone whose payment may fail.
The facilitator flow is:
Client → POST /api/your-endpoint with X-PAYMENT header
↓
Server → POST https://api.cdp.coinbase.com/platform/v2/x402/verify
↓ (only if verify returns valid)
Server → do the actual work (database query, LLM call, etc.)
↓ (only if work succeeds)
Server → POST https://api.cdp.coinbase.com/platform/v2/x402/settle
↓
Server → 200 with Payment-Receipt header
Libraries like x402-next and mppx implement this order correctly. If you're building your own middleware, this sequence isn't optional.
Replay Attacks: What Actually Prevents Them
x402 payments use EIP-3009 transferWithAuthorization. Each authorization includes a nonce — a random value that is burned on-chain when the transaction is submitted. The nonce makes replay prevention on-chain: a second submission of the same signed authorization will revert.
This means the on-chain layer handles replay prevention automatically — once a payment is settled, the nonce is gone and the same authorization can't be used again.
Where replay attacks can still happen:
If you use a facilitator for verify but settle manually (or vice versa), a race condition is possible. Two concurrent requests arrive with the same payment authorization. Both pass verification (the nonce isn't burned until settlement), and you do work for both before either settles. One settlement succeeds; one fails. But the client only paid once and got content twice.
The fix is to implement a short-lived settlement cache keyed on payment nonce:
// In-memory deduplication cache (use Redis in production)
const settlingNonces = new Set<string>()
async function settleOnce(nonce: string, settle: () => Promise<void>): Promise<void> {
if (settlingNonces.has(nonce)) {
throw new Error(`Duplicate settlement attempt for nonce ${nonce}`)
}
settlingNonces.add(nonce)
try {
await settle()
} finally {
// Evict after 120s — safely past any block confirmation window
setTimeout(() => settlingNonces.delete(nonce), 120_000)
}
}
The x402 SDK includes a SettlementCache for Solana (where the race condition is most common), but the same pattern applies to any chain. On-chain replay prevention handles the persistent case; the in-process cache handles concurrent requests.
Idempotency: The Retry Problem
Clients retry. Networks fail. A client may have paid successfully but not received a response (timeout, connection drop). On retry, it's ambiguous:
- Did the first request succeed? (payment processed, should use cached response)
- Did it fail pre-settlement? (payment not processed, should retry normally)
- Did it fail post-settlement? (payment processed but work failed, tricky)
The x402 payment-identifier extension addresses this. A client includes a unique payment-id in its request. The server caches the response keyed on that ID. Retries with the same ID get the cached response without reprocessing the payment.
Server implementation:
import { extractPaymentIdentifier } from '@x402/extensions/payment-identifier'
// After successful settlement
const paymentId = extractPaymentIdentifier(paymentPayload)
if (paymentId) {
await cache.set(paymentId, responseData, { ttl: 3600 })
}
// Before processing (check cache before hitting the payment gate)
const cached = paymentId ? await cache.get(paymentId) : null
if (cached) return Response.json(cached)
If you're using Redis for the cache (recommended for any distributed deployment), a TTL of 1 hour covers most retry windows without accumulating stale entries indefinitely.
Advertise the extension in your 402 response:
import {
declarePaymentIdentifierExtension,
PAYMENT_IDENTIFIER,
} from '@x402/extensions/payment-identifier'
// In your route config
extensions: {
[PAYMENT_IDENTIFIER]: declarePaymentIdentifierExtension(false) // optional for clients
}
Clients that support the extension will include a payment ID. Clients that don't will continue to work without idempotency guarantees.
Verifying Without a Facilitator
Using a facilitator (CDP, x402.org) is the standard path because it handles blockchain connectivity for you. But there are reasons to verify locally: lower latency, no third-party dependency, self-hosted infrastructure.
Local verification for the exact EVM scheme means:
- Decode the base64
X-PAYMENTheader - Validate the payload structure matches your declared requirements (
amount,asset,payTo,network,resource) - Check
validBefore > now(reject stale authorizations) - Verify the EIP-712 signature against the claimed
fromaddress
import { verifyAuthorization } from 'viem'
const isValid = await verifyAuthorization({
address: payload.from,
authorization: {
contractAddress: payload.asset,
value: BigInt(payload.amount),
nonce: payload.nonce,
deadline: BigInt(payload.validBefore),
},
signature: payload.signature,
})
The risk of local verification: you must still settle via on-chain transaction, which means maintaining a funded gas wallet, handling RPC connectivity, and implementing the SettlementCache yourself. If you're settling via on-chain transactions without a facilitator, you also take on full responsibility for duplicate detection.
For most production deployments, the facilitator is the right default. Local verification is worth considering if you're making hundreds of paid requests per minute and facilitator latency is measurable in your response time.
What to Log
Every paid request should produce a log entry that allows you to reconstruct what happened and correlate it with the on-chain record.
The Payment-Receipt header on a successful 200 response contains a base64-encoded JSON object:
{
"challengeId": "qB3wErTyU7iOpAsD9fGhJk",
"method": "exact",
"reference": "0xtxhash...",
"settlement": {
"amount": "1000",
"currency": "usd"
},
"status": "success",
"timestamp": "2026-04-07T12:00:00Z"
}
The reference field is the transaction hash (for EVM chains) or payment intent ID (for Stripe). This is how you correlate a server log entry with an on-chain record.
Minimum fields to log per paid request:
logger.info('x402_payment', {
// From the payment payload (before settlement)
from: payload.from, // payer wallet
payTo: payload.payTo, // your receiving address
amount: payload.amount, // amount in base units
asset: payload.asset, // token contract
network: payload.networkId, // chain
validBefore: payload.validBefore, // authorization expiry
nonce: payload.nonce, // replay prevention
// From the receipt (after settlement)
txHash: receipt.reference, // on-chain reference
challengeId: receipt.challengeId, // links back to the 402 challenge
settledAt: receipt.timestamp,
// From the request context
endpoint: req.nextUrl.pathname,
wallet: req.nextUrl.searchParams.get('wallet'), // if relevant
requestId: req.headers.get('x-request-id'),
})
This gives you:
- Auditing — every payment, by whom, for what
- Dispute resolution —
txHashlinks to the chain explorer - Reconciliation — match your server revenue against on-chain transfers
- Anomaly detection — payers making unusually many requests, payments that verify but don't settle, etc.
What Goes Wrong in Practice
Settle fires before work completes. If your middleware settles eagerly and your handler throws, the client pays for nothing. Verify the work succeeded before settling. Libraries handle this, but custom middleware often gets the order wrong.
Replay window during deployment. If you deploy a new server instance while a previous instance is mid-request, the settlement cache is lost. This is rare but the mitigation is using Redis (shared cache) rather than in-process memory for the deduplication set.
Expired authorization accepted. If your server's clock is drifted or if you're lenient about the validBefore check, you'll accept authorizations the client considers expired. Check validBefore > Date.now() / 1000 strictly. Reject with a clear error if expired.
No log for failed verifications. Verification failures — malformed payloads, wrong amounts, mismatched resource URLs — should be logged too. They're the signal for debugging client integration issues and detecting probing attacks.
paylog.dev's /api/v1/report endpoint is itself x402-gated on Base. If you want to audit the actual payment history for a wallet across x402 services, the API is there.