Skip to content
Quantix Security Labs
Primer

Signature replay across EVM chains

A signature proves that someone agreed to something. Unless the message says where and how often, it can be presented again on another chain, another contract or another day. The binding fields, and how they fail.

Written by
Quantix Research, Cross-Chain Security
Published
Reading time
6 minutes
Category
Cross-Chain Security

Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Illustration: one signed message accepted on chain A and replayed on chain B because nothing binds it to a single chain.
Contents
  1. What a signature proves
  2. Binding a signature to one use
  3. Replay across chains
  4. Replay across contracts and versions
  5. Replay on the same contract
  6. Malleability and recovery pitfalls
  7. Smart accounts and EIP-1271
  8. A catalogue of replay
  9. Conclusions
  10. Recommendations
  11. Bibliography

Off-chain signatures are everywhere in modern EVM applications: gasless approvals, meta-transactions, order books, airdrop claims, governance votes and cross-chain messages. Each one lets a contract act on a statement that someone signed earlier, somewhere else. The contract has to answer a question the signature alone does not: is this the occasion the signer meant?

When the answer is not built into the signed message, the same signature can be presented again: on another chain, to another contract, a second time on the same contract, or long after it was meant to expire. This primer walks through each form of replay, the field that prevents it, and the implementation details that undo those fields.

What a signature proves

An ECDSA signature over a hash proves that the holder of a private key signed that hash. Nothing more. It does not say which chain the signer was looking at, which contract they meant, or whether they intended the authorisation to be used once. If two situations produce the same hash, a signature for one is valid for the other.

Replay protection is therefore a matter of making sure that every situation the signer did not intend produces a different hash. EIP-712, the standard for typed structured data, provides the tools; using them correctly is the application’s job.

Binding a signature to one use

What each field of an EIP-712 signature bindsThe signed digest is keccak256 of the bytes 0x19 0x01, the domain separator and the hash of the message struct. Domain fields bind the signature to a name and version, a chain ID, a verifying contract and optionally a salt. Message fields bind what is authorised, a nonce that prevents reuse on the same contract, and a deadline that prevents use much later.digest = keccak256( 0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(message) )Domain separatorMessage (Permit, for example)FIELDSTOPS REPLAY ONFIELDPURPOSEname, versionanother deployment or versionchainIdanother chain, including forksverifyingContractanother contractsalt (optional)an app sharing the other fieldsowner, spender, valuestates what is authorisednoncestops reuse on the same contractdeadlinestops use much later
The EIP-712 digest and what each field binds. A field left out of the domain or the message is a dimension along which the signature can be replayed.

The digest combines a domain separator, which identifies the application and where it runs, with the hash of the message, which states what is authorised. The domain fields defined by EIP-712 are name, version, chainId, verifyingContract and salt, all optional in the standard. That optionality is where many replay bugs begin.

Compare a claim function that signs only the recipient and amount with one that uses the full structure:

RewardsV1.sol (illustrative, vulnerable)solidity
// Simplified, illustrative contract. Not production code.contract RewardsV1 {    address public immutable signer;    IERC20  public immutable token;     function claim(address to, uint256 amount, bytes calldata sig) external {        bytes32 h = keccak256(abi.encodePacked(to, amount));     // no nonce, chain or contract        address s = ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(h), sig);        require(s == signer, "bad signature");        token.transfer(to, amount);    }}
RewardsV2.sol (illustrative)solidity
// The same claim, bound to one chain, one contract, one use and one time window.contract RewardsV2 is EIP712, Nonces {    bytes32 private constant CLAIM_TYPEHASH =        keccak256("Claim(address to,uint256 amount,uint256 nonce,uint256 deadline)");     address public immutable signer;    IERC20  public immutable token;     constructor(address signer_, IERC20 token_) EIP712("Rewards", "2") {        signer = signer_;        token = token_;    }     function claim(address to, uint256 amount, uint256 deadline, bytes calldata sig) external {        if (block.timestamp > deadline) revert Expired();        bytes32 structHash = keccak256(abi.encode(CLAIM_TYPEHASH, to, amount, _useNonce(to), deadline));        // _hashTypedDataV4 adds the domain: name, version, chainId and this contract's address.        address s = ECDSA.recover(_hashTypedDataV4(structHash), sig);        if (s != signer) revert BadSignature();        SafeERC20.safeTransfer(token, to, amount);    }}

The first version accepts the same signature any number of times, on any chain where it is deployed, and on any other contract that uses the same signer and hash construction. The second binds the signature to one chain and one contract through the domain, to one use through the nonce, and to a time window through the deadline.

Replay across chains

Ordinary transactions faced this problem first. Before EIP-155, a signed transaction did not commit to a chain, so a transaction broadcast on one network could be rebroadcast on another that shared the same account history. EIP-155 added the chain ID to the signed transaction data.

Application signatures need the same protection, and it is easy to get wrong in two ways. The first is to omit chainId from the domain, which makes a signature valid on every chain where the contract is deployed at the same address, a common situation with deterministic deployment. The second is to compute the domain separator once, at deployment, and store it. If the chain later forks and the fork adopts a new chain ID, the stored separator still matches the old one, and signatures from one side are valid on the other. OpenZeppelin’s EIP712 base contract caches the separator but rebuilds it whenever block.chainid or the contract address differs from the cached values.

Cross-chain messaging extends the problem: a message must bind its source chain, destination chain and destination contract. The bridge exploit analysis places these failures in the wider bridge attack surface.

Replay across contracts and versions

verifyingContract binds a signature to one deployment. Without it, two contracts that share a signer and a message format, such as a token on two different markets or a v1 and v2 of the same application, accept each other’s signatures. name and version separate applications and releases that might otherwise share a domain, which matters when a new version is deployed behind the same address through a proxy.

Replay on the same contract

A nonce makes each signature single-use. Sequential nonces, as in EIP-2612 permits, are simple but force the signer to use signatures in order; a signature with nonce 5 is unusable until nonce 4 has been consumed. Applications that need signatures to be used independently, such as order books, use unordered nonces. Uniswap’s Permit2, for example, tracks nonces in a bitmap so that any unused nonce can be consumed in any order.

A deadline bounds how long a signature remains usable, which limits the damage if a signature leaks or the signer changes their mind. Cancellation, by consuming a nonce on-chain or invalidating a range, should be possible for every signature type an application accepts.

Malleability and recovery pitfalls

For every valid ECDSA signature there is a second valid signature over the same hash, obtained by replacing s with the curve order minus s and flipping v. The precompile behind ecrecover accepts both. A contract that prevents replay by recording used signatures, rather than used nonces or message hashes, can be bypassed by submitting the other form. OpenZeppelin’s ECDSA.recover rejects signatures whose s is in the upper half of the range, which removes the second form.

Recovery has a second pitfall: ecrecover returns the zero address for an invalid signature instead of reverting. If the expected signer is ever the zero address, for instance because it was never initialised, every invalid signature passes the check.

Smart accounts and EIP-1271

EIP-1271 lets a contract, typically a smart account, declare whether a signature is valid on its behalf through isValidSignature(hash, signature). A simple implementation checks that the account’s owner signed hash. If one key owns several smart accounts, the same owner signature is then valid for all of them, and a permit or order intended for one account can be replayed against another. ERC-7739 addresses this with defensive rehashing that includes the account’s address, while keeping the signed content readable in wallets.

A catalogue of replay

ReplayMissing bindingTypical causeFix
Another chainchainIdField omitted, or separator cached across a forkUse EIP-712 with a separator rebuilt when block.chainid changes
Another contractverifyingContractShared signer across deploymentsInclude the verifying contract in the domain
Another versionname, versionUpgrade behind the same addressBump the version on breaking changes
Same contract, againNonceNo nonce, or signature bytes used as the keyConsume a nonce per signature
Same contract, laterDeadlineNo expiry on long-lived authorisationsEnforce a deadline; support cancellation
Another smart accountAccount addressEIP-1271 check of the owner’s signature onlyRehash with the account address (ERC-7739)
Forms of signature replay, the binding that prevents each, and how the binding is commonly undone

The cross-chain message replay case study and the Axeline Bridge research assessment show how missing bindings are found and reproduced in a review.

Conclusions

A signature authorises a hash, and replay protection consists of making every unintended context produce a different hash. EIP-712 provides the fields; replay bugs come from leaving them out, computing them once when they can change, or tracking the wrong thing as used.

The checklist is short and mechanical: domain with chain ID and verifying contract, a nonce consumed on use, a deadline, signature normalisation, a non-zero signer, and account binding for smart accounts.

Recommendations

Developers

  1. Use a maintained EIP-712 implementation, and include chainId and verifyingContract in every domain.
  2. Consume a nonce for every signature and enforce a deadline; offer on-chain cancellation.
  3. Recover signers with a library that rejects malleable signatures, and reject a zero-address signer.

Smart account builders

  1. Bind EIP-1271 validation to the account address, for example with ERC-7739 rehashing.

Reviewers

  1. For every signed message type, list the contexts in which it is valid and confirm each is intended.
  2. Test replay explicitly: on a second deployment, on a forked chain ID, and with the alternative s value.

Bibliography

Primary sources: standards, official documentation, incident reports and papers.

  1. 1EIP-712: Typed structured data hashing and signing(opens in a new tab)Ethereum Improvement Proposals
  2. 2EIP-155: Simple replay attack protection(opens in a new tab)Ethereum Improvement Proposals
  3. 3EIP-2612: Permit extension for EIP-20 signed approvals(opens in a new tab)Ethereum Improvement Proposals
  4. 4EIP-1271: Standard signature validation method for contracts(opens in a new tab)Ethereum Improvement Proposals
  5. 5ERC-7739: Readable typed signatures for smart accounts(opens in a new tab)Ethereum Improvement Proposals
  6. 6Cryptography utilities: ECDSA, EIP712, MessageHashUtils(opens in a new tab)OpenZeppelin Contracts 5.x API
  7. 7SWC-121: Missing protection against signature replay attacks(opens in a new tab)Smart Contract Weakness Classification Registry
  8. 8SWC-117: Signature malleability(opens in a new tab)Smart Contract Weakness Classification Registry
  9. 9Permit2 overview(opens in a new tab)Uniswap documentation

Applied on this site

Assessments, case studies and tools that apply this publication.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.