Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 5 min read

AI agent's USDC payment might stay valid for a week. Here's how to check before it signs

When an AI agent pays for an API with x402, it doesn't send a transaction. It signs a message that says "move 0.01 USDC from me to you, valid between time A and time B." Someone else submits that message on-chain later.

AI agent's USDC payment might stay valid for a week. Here's how to check before it signs

When an AI agent pays for an API with x402, it doesn't send a transaction. It signs a message that says "move 0.01 USDC from me to you, valid between time A and time B." Someone else submits that message on-chain later.

That signature is the payment. If its valid window is wide open, the payment is too. This post shows what's inside the signature, three ways a long window bites an agent, and a short Python check to run before the agent signs anything.

What the agent actually signs

The x402 exact scheme on EVM chains uses EIP-3009 transferWithAuthorization. The agent signs these six fields with EIP-712:

The six EIP-3009 fields an agent signs: from, to, value, validAfter, validBefore and nonce

The server, or a facilitator acting for it, checks the signature, balance, amount, time window and nonce, then calls transferWithAuthorization to settle.

Those checks confirm the signature is valid right now. Nothing in them limits how long the agent chose to make it valid. That choice is yours.

Three ways a long window goes wrong

1. The signature works like cash until it expires

EIP-3009 lets anyone call transferWithAuthorization with a valid signature. That's what makes relayers possible. It also means anyone who gets a copy can settle it: a logging proxy, a debug trace, a server that stores requests, or a compromised dependency.

With a 60-second window, a leaked signature is useless a minute later. With a 7-day window, it works all week.

2. Retries can pay twice

This is the one that catches real agents. The flow looks like this:

  1. The agent signs payment A (nonce 0xaa...) and sends it.
  2. The request times out. The agent doesn't know whether it went through.
  3. The retry logic signs payment B with a fresh random nonce (0xbb...) and sends it.
  4. Both settle, because they have different nonces and both are inside their windows.

Nonces stop the same signature from being used twice. They don't stop two different signatures for the same purchase. Short windows limit the damage, and the real fix is in the retry logic: resend the same signed authorization instead of signing a new one. Only one copy of a nonce can ever settle.

3. It's signed for the wrong token or chain

The EIP-712 domain ties the signature to a chain ID and a token contract. If the 402 response points the agent at a different contract or chain, the agent may happily sign for an asset it never meant to pay with. Check the domain against what you expect before signing.

The check: run it before every signature

Install one dependency:

pip install eth-account

Then put a check in front of the signing step. This version is set up for USDC on Base Sepolia. Change the constants for your chain.

import os
import time
from eth_account import Account

# --- Your agent's policy ---
CHAIN_ID = 84532  # Base Sepolia
USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
ALLOWED_PAYEES = {"0x1111111111111111111111111111111111111111"}
MAX_VALUE = 1_000_000       # 1 USDC (6 decimals)
MAX_WINDOW_SECONDS = 120    # signature dies 2 minutes after signing

used_nonces: set[str] = set()  # persist this in a real agent


def build_authorization(payer: str, pay_to: str, value: int) -> dict:
    now = int(time.time())
    return {
        "from": payer,
        "to": pay_to,
        "value": value,
        "validAfter": now - 5,     # small allowance for clock skew
        "validBefore": now + 60,   # short-lived on purpose
        "nonce": "0x" + os.urandom(32).hex(),
    }


def check_before_signing(auth: dict, domain: dict) -> list[str]:
    problems = []
    now = int(time.time())

    if domain["chainId"] != CHAIN_ID:
        problems.append(f"wrong chain: {domain['chainId']}")
    if domain["verifyingContract"].lower() != USDC.lower():
        problems.append("token contract is not the USDC we expect")
    if auth["to"] not in ALLOWED_PAYEES:
        problems.append(f"unknown payee: {auth['to']}")
    if auth["value"] > MAX_VALUE:
        problems.append(f"value {auth['value']} is over the cap")
    if auth["validAfter"] > now:
        problems.append("authorization only starts in the future")
    if auth["validBefore"] - now > MAX_WINDOW_SECONDS:
        hours = (auth["validBefore"] - now) / 3600
        problems.append(f"signature stays valid for {hours:.1f} hours")
    if auth["nonce"] in used_nonces:
        problems.append("nonce already used")
    return problems

And the signing step, which only runs after the check passes:

def sign(auth: dict, domain: dict, key: str) -> str:
    typed = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
                {"name": "verifyingContract", "type": "address"},
            ],
            "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"},
            ],
        },
        "primaryType": "TransferWithAuthorization",
        "domain": domain,
        "message": auth,
    }
    signed = Account.sign_typed_data(key, full_message=typed)
    used_nonces.add(auth["nonce"])
    return "0x" + signed.signature.hex().removeprefix("0x")

Try it

agent = Account.create()
domain = {"name": "USDC", "version": "2",
          "chainId": CHAIN_ID, "verifyingContract": USDC}
payee = next(iter(ALLOWED_PAYEES))

good = build_authorization(agent.address, payee, 10_000)  # 0.01 USDC
print("good:", check_before_signing(good, domain) or "OK to sign")
sign(good, domain, agent.key)

risky = dict(good, nonce="0x" + os.urandom(32).hex(),
             validBefore=int(time.time()) + 7 * 24 * 3600)
print("risky:", check_before_signing(risky, domain))

replay = dict(good)  # same nonce as the payment we already signed
print("replay:", check_before_signing(replay, domain))

Output:

good: OK to sign
risky: ['signature stays valid for 168.0 hours']
replay: ['nonce already used']

The 7-day signature is caught before it exists, and so is a second signature with a nonce the agent already used.

Two more things worth doing

  • Before a retry, check whether the first payment settled. The token contract's authorizationState(authorizer, nonce) returns true once a nonce is used. If it's false and you still want to pay, resend the same signed authorization rather than signing a new one.
  • Cancel what you won't use. EIP-3009 defines an optional cancelAuthorization. If your token implements it, cancelling an unused nonce kills a signature before its window closes.

Where a model fits

The rules above are cheap and should run on every payment. They can't judge everything, though. Is this new payee plausible for what the user asked? Does this 402 response contain text trying to talk the agent into paying more? For those gray areas I trained SRA-RiskGate-4B, a small open model that runs locally and returns approve, hold or reject with reason codes. It sits behind the deterministic checks, not in place of them.

If you want the bigger picture of how x402 compares with ACP, AP2 and MPP, and why stablecoin payments have no chargeback to fall back on, I wrote about that here: No Chargebacks for AI Agents: Check Payments Before They Sign.

What window does your agent sign with today? I'd like to hear what others use.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.