Replay Protection: Chains, Contracts, Nonces

A correctly-verifiable signature is necessary but not sufficient. The remaining question is: when can this signature legitimately be used, and when has its window passed? A signature without proper replay protection is reusable forever, by anyone who holds it, against any compatible contract on any chain.

Replay risk has multiple axes. Each one must be addressed.

Axis 1: Single-Use vs. Multi-Use

Most signed messages are intended for one-time use: a permit, an order, an authorization. They must be consumable exactly once. Multi-use messages exist (e.g., a long-lived API key signed by an issuer) but are rare and should be flagged for extra scrutiny.

The mechanism is a nonce: a monotonically increasing or unique value that, once consumed, cannot be reused.

mapping(address => uint256) public nonces;

function consume(uint256 nonce, ..., bytes memory sig) external {
    require(nonce == nonces[msg.sender], "bad nonce");
    nonces[msg.sender] = nonce + 1;
    // verify sig over a hash that includes nonce
}

Audit checklist for nonces:

  • The nonce is included in the signed hash. (A nonce that the contract increments but the signer doesn't sign is useless.)
  • The nonce is incremented before any external call or risky operation, to prevent re-entrant replay.
  • The nonce space is appropriate for the use case:
    • Per-signer sequential nonces (default for Permit): simple, but orderings must be respected.
    • Bitmap nonces (Permit2, Seaport): two-dimensional (nonceKey, nonceBit), allowing cancellations and out-of-order use.
    • Single-use unique IDs (UUIDs, signed counter values): fine if collision-resistant.
  • There is a way for the signer to cancel outstanding signatures — typically by incrementing the nonce manually. Without cancellation, a signed message issued in error cannot be revoked.

The bitmap-nonce pattern (used by Permit2 and Seaport) is the modern best practice for protocols with many concurrent off-chain orders: each order picks a (key, bit) location, and consuming the order flips the bit. This allows arbitrary cancellation and avoids the "stuck order" problem of strict sequential nonces.

Axis 2: Chain Replay

Without chain ID in the signed message, a signature valid on chain X is also valid on chain Y if the same contract is deployed there at the same address. The classic example:

  • A user signs a Permit on Ethereum mainnet, authorizing 100 USDC to be spent.
  • The same contract is deployed on Polygon at the same address (CREATE2 makes this trivially achievable).
  • A relayer replays the signature on Polygon, draining 100 USDC there from the user's address.

EIP-712 includes chainId in the domain separator precisely to prevent this. Audit checklist:

  • chainId is in the domain separator.
  • chainId is read from block.chainid dynamically, not stored as immutable at deployment.
  • If the contract caches the domain separator, it correctly rebuilds it when block.chainid != cachedChainId (covering chain forks like the post-Merge ETHW fork).

A contract that fails the chain replay test exposes its users to cross-chain signature replay whenever the contract is multi-chain.

Axis 3: Contract Replay

Without the verifying contract's address in the signed message, a signature valid for contract A is also valid for contract B if they use the same encoding scheme. This was a real bug class in the early days of Permit, where some implementations omitted verifyingContract from the domain separator.

EIP-712 includes verifyingContract in the domain separator. The audit checklist mirrors chainId:

  • verifyingContract is in the domain separator.
  • It is computed dynamically as address(this), not cached at deployment (relevant for cloned/factory deployments).

Axis 4: Time Replay (Deadlines)

A signature that never expires is a signature that can be replayed indefinitely. Even with a nonce that the signer can cancel, a long-outstanding signature is exposure: if the nonce is never used, the message is valid forever.

Every meaningful signed message should include a deadline:

bytes32 PERMIT_TYPEHASH = keccak256(
    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);

require(block.timestamp <= deadline, "expired");

Audit checklist:

  • deadline is in the signed hash (as a field of the struct).
  • The contract enforces block.timestamp <= deadline before any state mutation.
  • Default deadlines in client libraries are reasonable (minutes to hours, not years).
  • No code path bypasses the deadline check.

A deadline = type(uint256).max defeats the purpose. UIs that default to "never expires" should be flagged.

Axis 5: Function / Action Replay

A signed message authorizing action X should not be replayable to authorize action Y. The signed hash must uniquely identify which action is being authorized.

The typehash provides this for EIP-712:

bytes32 PERMIT_TYPEHASH       = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 TRANSFER_TYPEHASH     = keccak256("Transfer(address from,address to,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 CANCEL_ORDER_TYPEHASH = keccak256("CancelOrder(bytes32 orderId,uint256 nonce,uint256 deadline)");

A signature for Permit cannot be used as a Transfer because the typehashes differ, so the struct hashes differ, so the final signing digest differs.

Audit notes:

  • Every distinct kind of signed message should have a distinct typehash.
  • The typehash strings should be hardcoded as keccak256 constants, not constructed dynamically (to avoid runtime stringification bugs).
  • The typehash should encode all the semantically-meaningful fields. Omitting a field (e.g., signing a Permit without spender) lets the consumer rewrite that field at execution time.

Axis 6: Cross-Contract / Cross-Function Replay Within the Same App

Subtler: even within one contract, two different functions can verify signatures the same way and be exploited interchangeably. Example:

function approveSwap(uint256 amount, bytes32 hash, ...) external {
    require(ecrecover(hash, ...) == signer, "bad sig");
    ...
}
function approveWithdrawal(uint256 amount, bytes32 hash, ...) external {
    require(ecrecover(hash, ...) == signer, "bad sig");
    ...
}

If the same hash could mean either operation, an attacker replays a swap approval as a withdrawal approval. The fix is per-function typehashes (the EIP-712 solution) or explicit function discriminators in the hashed payload.

Axis 7: Order-of-Operations Replay

Two messages with the same nonce are normally caught by the nonce check, but what about consumption order? Some patterns let the consumer decide which message to consume first:

function consume(bytes32 message1, sig1, bytes32 message2, sig2) external {
    // verifies both, then acts
}

If both messages reference the same nonce, the contract picks one. If both messages have different effects (one increments a counter, one decrements), the attacker chooses which.

This is rare but appears in batch-execution and meta-transaction designs. Audit: every signed message should have a uniquely-identifying nonce-space slot, not just "the next nonce."

A Worked Example: A Correct Permit Verification

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external {
    require(block.timestamp <= deadline, "expired");

    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TYPEHASH,
        owner,
        spender,
        value,
        nonces[owner]++,
        deadline
    ));

    bytes32 digest = keccak256(abi.encodePacked(
        "\x19\x01",
        DOMAIN_SEPARATOR(),  // function that returns dynamic separator
        structHash
    ));

    address signer = ECDSA.recover(digest, v, r, s);
    require(signer == owner, "bad signer");

    _approve(owner, spender, value);
}

Walking through the replay-protection checklist:

  • Single-use: yes, nonces[owner]++ consumes the nonce.
  • Chain replay: yes, DOMAIN_SEPARATOR() includes block.chainid.
  • Contract replay: yes, DOMAIN_SEPARATOR() includes address(this).
  • Time replay: yes, deadline check is enforced.
  • Function replay: yes, PERMIT_TYPEHASH is unique to this operation.

This is, modulo wrapper details, the canonical OpenZeppelin Permit implementation. New code should follow it (or use the library directly).

Common Anti-Patterns

  • Caching the domain separator in an immutable variable computed at deployment, then never recomputing it on chain forks.
  • Storing nonces but not including them in the signed hash.
  • Using block.number instead of block.timestamp for deadlines (block times are not constant; intentions don't match block counts).
  • A deadline parameter that the signer signs but the contract doesn't check.
  • No deadline at all for "convenience".
  • A single shared mapping for all signature types, where consuming a Permit-nonce also consumes a Transfer-nonce.
  • Public functions that bypass nonce checks in "admin" pathways.

Every one of these is a real audit finding template, recurring across years and ecosystems. The fixes are mechanical once the bug is recognized; the goal of this section is to make recognition routine.