Signature Replay Attacks: Why Off-Chain Signatures Are Hard to Get Right

Signatures provide a means of cryptographic authentication in blockchain technology, serving as a unique “fingerprint” that forms the backbone of blockchain transactions. They are used to validate computation performed off-chain and authorize transactions on behalf of a signer. That power is also their liability. A signature that is valid once can be valid forever if the contract consuming it doesn’t explicitly bound it to a specific context.

Logically, a transaction, once signed, should be executed only once. If a transaction can be executed multiple times, it poses a risk of a replay attack. This article walks through every axis of that risk — same-chain, cross-chain, and cross-contract — and gives you the Solidity patterns to close each one.


What Is a Signature Replay Attack?

A signature replay vulnerability results in the verification of a single signature multiple times. In such scenarios, attackers reuse the same signature to pass multiple authorization checks for payments, illegally obtaining assets and compromising user trust and funds.

The attacker watches for transactions that include off-chain signatures — such as meta-transactions, permit approvals, or gasless transfers — which are publicly visible in calldata. The attacker then extracts the signature and the signed message parameters from the transaction calldata. Since everything is on-chain, this requires zero special access.

The attacker then submits the identical signature and parameters to the same contract (same-chain replay), a different contract (cross-contract replay), or the same contract on another chain (cross-chain replay). Without nonce tracking, chain ID binding, or contract address binding, the signature passes verification every time.


The Three Replay Dimensions

1. Same-Chain Replay

Same-chain signature replay attacks usually exploit contract vulnerabilities. The most typical situation is when the contract does not include a nonce when generating a signature, resulting in the signature data being infinitely usable and causing harm.

The simplest possible vulnerable contract looks like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// ❌ VULNERABLE: No nonce, no chain ID, no contract binding
contract VulnerableWithdraw {
    mapping(address => uint256) public balances;
    address public owner;

    constructor() { owner = msg.sender; }

    function withdraw(
        address to,
        uint256 amount,
        bytes memory signature
    ) external {
        bytes32 msgHash = keccak256(abi.encodePacked(to, amount));
        bytes32 ethHash = keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n32", msgHash)
        );
        address signer = ecrecover(ethHash, _v(signature), _r(signature), _s(signature));
        require(signer == owner, "Invalid signer");
        // ❌ signature can be replayed indefinitely
        payable(to).transfer(amount);
    }

    function _v(bytes memory sig) internal pure returns (uint8 v) { assembly { v := byte(0, mload(add(sig, 96))) } }
    function _r(bytes memory sig) internal pure returns (bytes32 r) { assembly { r := mload(add(sig, 32)) } }
    function _s(bytes memory sig) internal pure returns (bytes32 s) { assembly { s := mload(add(sig, 64)) } }
}

An attacker who observes the first legitimate withdrawal call can copy the calldata and replay it any number of times, draining the contract on every replay until funds are exhausted.

2. Cross-Chain Replay

Cross-chain replay attacks arise when signatures can be reused across different blockchain systems. Once a signature has been used and invalidated on one chain, an attacker can still copy it, use it on another, and trigger an unwanted state change. This poses a significant threat to smart contract systems deployed across chains with identical code.

A cross-chain signature replay, as the name suggests, replays transactions on different chains to complete an attack. The most notorious real-world example is the theft of 20 million OP tokens from Optimism on June 9, 2022. In that incident, the chain ID was missing from the hash calculation, meaning that the same operation could be replayed on a different chain for the same smart contract account.

To prevent cross-chain signature replay attacks, smart contracts must validate the signature using the chain ID, and users must include the chain ID in the message to be signed.

// ❌ VULNERABLE: Same message hash works on any EVM chain
bytes32 msgHash = keccak256(abi.encodePacked(to, amount, nonces[msg.sender]));

// ✅ FIXED: Chain ID bound into the message
bytes32 msgHash = keccak256(abi.encodePacked(
    block.chainid,   // <-- ties to this exact chain
    address(this),   // <-- ties to this exact contract
    to,
    amount,
    nonces[msg.sender]
));

3. Cross-Contract Replay

Without domain separation, a signature valid for DApp A could be replayed on DApp B, and version confusion can allow old signatures on upgraded contracts.

Imagine two protocols deployed to separate addresses, both accepting a signed Transfer(address to, uint256 amount, uint256 nonce) struct without binding the signature to their specific contract address. A user’s signature obtained by Protocol A can be submitted verbatim to Protocol B, which will recover the same signer address and happily execute.

The domain separator is a mandatory field to avoid signature collision. It is entirely possible that two smart contracts on the same chain define the same data schema. By providing a unique domain separator, there is no problem with defining identical data schemes.


EIP-712: The Structured Signature Standard

EIP-712 is the standard for structured data hashing and signing that enables users to sign human-readable typed messages rather than opaque byte strings, providing domain separation to prevent replay attacks across different applications and contexts while maintaining cryptographic security through standardized encoding.

EIP-712 standardizes how structured data is encoded, hashed, and signed, ensuring compatibility between off-chain systems and Ethereum smart contracts. It provides human-readable messages, making it easier for users to verify the content of the data being signed, and includes domain-specific details like chain ID and contract address to prevent signature reuse across different domains or contracts.

The Domain Separator — All Required Fields

Each EIP-712 signature includes a domain containing: name (human-readable protocol identifier), version (protocol version preventing cross-version replays), chainId (Ethereum chain identifier preventing cross-chain replays), verifyingContract (contract address that will verify the signature), and optionally salt (additional entropy).

From the canonical EIP-712 specification:

  • uint256 chainId — the EIP-155 chain ID. The user-agent should refuse signing if it does not match the currently active chain.
  • address verifyingContract — the address of the contract that will verify the signature. The user-agent may do contract-specific phishing prevention.
  • bytes32 salt — a disambiguating salt for the protocol. This can be used as a domain separator of last resort.

The EIP712Domain fields should appear in the order defined above, skipping any absent fields.

Here is a complete, production-ready domain separator implementation without relying on the OpenZeppelin base contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract EIP712Domain {
    // ✅ All five domain fields — skip none that matter for your threat model
    bytes32 private constant DOMAIN_TYPE_HASH = keccak256(
        "EIP712Domain("
            "string name,"
            "string version,"
            "uint256 chainId,"
            "address verifyingContract,"
            "bytes32 salt"
        ")"
    );

    bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
    uint256 private immutable _CACHED_CHAIN_ID;
    bytes32 private immutable _HASHED_NAME;
    bytes32 private immutable _HASHED_VERSION;
    bytes32 private immutable _SALT;

    constructor(string memory name, string memory version, bytes32 salt) {
        _HASHED_NAME    = keccak256(bytes(name));
        _HASHED_VERSION = keccak256(bytes(version));
        _SALT           = salt;
        _CACHED_CHAIN_ID = block.chainid;
        _CACHED_DOMAIN_SEPARATOR = _buildSeparator();
    }

    // ✅ Recompute on chain ID change (handles hard-fork scenarios)
    function _domainSeparator() internal view returns (bytes32) {
        if (block.chainid == _CACHED_CHAIN_ID) {
            return _CACHED_DOMAIN_SEPARATOR;
        }
        return _buildSeparator();
    }

    function _buildSeparator() private view returns (bytes32) {
        return keccak256(abi.encode(
            DOMAIN_TYPE_HASH,
            _HASHED_NAME,
            _HASHED_VERSION,
            block.chainid,        // ← cross-chain protection
            address(this),        // ← cross-contract protection
            _SALT                 // ← additional disambiguation
        ));
    }

    // EIP-712 final digest
    function _hashTypedData(bytes32 structHash) internal view returns (bytes32) {
        return keccak256(abi.encodePacked(
            "\x19\x01",
            _domainSeparator(),
            structHash
        ));
    }
}

Why cache and recompute? The implementation of the domain separator was designed to be as efficient as possible while still properly updating to invalidate the cached domain separator if the chain ID changes. A hard fork or L2 network change can alter block.chainid at runtime.


Nonce Tracking: Sequential vs. Bitmap

Nonces are the primary same-chain deduplication mechanism. Using a digital signature, all other function parameters and a nonce are signed to make their integrity provable. The nonce to be passed is determined by the smart contract and changes after each function call. There are two dominant patterns.

Sequential Nonces

The simplest approach: each address has a counter that increments monotonically.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SequentialNonce is EIP712Domain {
    using ECDSA for bytes32;

    bytes32 private constant TRANSFER_TYPEHASH = keccak256(
        "Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)"
    );

    mapping(address => uint256) public nonces;

    event Transferred(address indexed from, address indexed to, uint256 amount);

    constructor()
        EIP712Domain("MyProtocol", "1", bytes32(0))
    {}

    function transfer(
        address to,
        uint256 amount,
        uint256 deadline,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        // ✅ Deadline check — must come before state changes
        require(block.timestamp <= deadline, "Signature expired");

        // ✅ Build struct hash with current nonce (pre-increment)
        uint256 currentNonce = nonces[msg.sender];
        bytes32 structHash = keccak256(abi.encode(
            TRANSFER_TYPEHASH,
            to,
            amount,
            currentNonce,
            deadline
        ));

        // ✅ Recover signer using OZ ECDSA (handles zero-address and malleability)
        address signer = _hashTypedData(structHash).recover(v, r, s);
        require(signer == msg.sender, "Invalid signature");

        // ✅ Increment AFTER verification, before external call (CEI)
        nonces[msg.sender] = currentNonce + 1;

        // Effect: state change complete before external interaction
        emit Transferred(msg.sender, to, amount);
        // ... execute transfer logic
    }
}

Sequential nonces require in-order execution. A user with three pending signed messages must submit them in exact order, or later ones will stall. This is often fine for simple approval flows but becomes a UX problem in high-throughput or batch scenarios.

Bitmap Nonces (Unordered)

For unordered execution, consider Uniswap’s Permit2 bitmap nonce pattern. Bitflip replay protection: the smart contract has a bitmap and every meta-transaction will flip a bit in the map. A 256-bit bitmap resets when 256 meta-transactions are processed, supporting up to 256 meta-transactions at a time in any order.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/**
 * @notice Bitmap nonce tracker — allows out-of-order, concurrent signature use.
 *
 * Nonce encoding:
 *   - Upper 248 bits = word position (which uint256 slot)
 *   - Lower 8 bits   = bit position within that slot (0-255)
 *
 * This mirrors Uniswap Permit2's nonceBitmap layout.
 */
contract BitmapNonce {
    // owner => wordPos => bitmap
    mapping(address => mapping(uint256 => uint256)) public nonceBitmap;

    error InvalidNonce();

    /// @notice Consume a nonce. Reverts if already used.
    function _useNonce(address owner, uint256 nonce) internal {
        uint256 wordPos = nonce >> 8;           // upper 248 bits
        uint256 bitPos  = nonce & 0xFF;         // lower 8 bits

        uint256 bit     = 1 << bitPos;
        uint256 flipped = nonceBitmap[owner][wordPos] ^= bit;

        // ✅ After XOR, the bit must be SET (1) — if it became 0, it was already set
        if (flipped & bit == 0) revert InvalidNonce();
    }

    /// @notice Check whether a nonce has been consumed.
    function isNonceUsed(address owner, uint256 nonce) public view returns (bool) {
        uint256 wordPos = nonce >> 8;
        uint256 bitPos  = nonce & 0xFF;
        return (nonceBitmap[owner][wordPos] >> bitPos) & 1 == 1;
    }
}

The bitmap approach trades sequential ordering guarantees for parallelism: a user can pre-sign dozens of independent operations, each with a distinct nonce, and they can be settled in any order without blocking one another.


Expiry and Deadline Enforcement

A deadline adds a timestamp to the signed message, and the contract rejects signatures where block.timestamp > deadline. It should be used as an additional layer alongside nonces, and is especially important for permit/approval signatures where stale signatures can be exploited at the worst possible moment.

A deadline is not sufficient alone — an attacker can still replay within the deadline window. Always combine with nonces.

// ✅ Correct deadline enforcement
modifier notExpired(uint256 deadline) {
    require(block.timestamp <= deadline, "Deadline passed");
    _;
}

// ❌ Common mistake: using >= instead of <=
// require(block.timestamp >= deadline, "Too early");   // inverted — always valid after deadline

// ❌ Another common mistake: checking after state change
function badOrder(uint256 deadline, ...) external {
    nonces[msg.sender]++;          // state changed first
    require(block.timestamp <= deadline, "Expired");  // revert too late
}

// ✅ Correct order: Check-Effects-Interactions
function goodOrder(uint256 deadline, ...) external {
    require(block.timestamp <= deadline, "Expired"); // CHECK
    nonces[msg.sender]++;                            // EFFECT
    // ... INTERACTION
}

Deadline design considerations:

ScenarioRecommended deadline
User-signed permitShort (minutes to hours)
Protocol governance voteMedium (hours to days)
Off-chain order booksEmbed in order struct, match expiry
Batch operationsPer-item deadline, not global

Never accept deadline = 0 as “no deadline.” Require an explicit future timestamp or a sentinel value your protocol documents, and reject the zero value explicitly.


Signature Malleability and Why to Use OpenZeppelin ECDSA

Raw ecrecover is dangerous for two independent reasons.

The Zero Address Problem

One of the most common vulnerabilities is missing validation when ecrecover encounters errors and returns an invalid address. A crucial check for address(0) is absent in many implementations. This omission allows an attacker to submit invalid signatures with arbitrary payloads yet pass as valid.

Since the ecrecover precompile fails silently and just returns the zero address as signer when given malformed messages, it is important to ensure owner != address(0) to avoid a permit from creating an approval on behalf of a nonexistent account.

// ❌ VULNERABLE: No zero-address check
address signer = ecrecover(digest, v, r, s);
require(signer == expectedSigner, "Bad sig");
// If ecrecover fails it returns address(0).
// If expectedSigner is also address(0) (e.g. uninitialized), this PASSES.

// ✅ SAFE: Explicit zero-address guard
address signer = ecrecover(digest, v, r, s);
require(signer != address(0), "Invalid signature");
require(signer == expectedSigner, "Wrong signer");

The Malleability Problem

In Ethereum, an ECDSA signature is represented by two 32-byte values r and s, and a one-byte recovery value v. The symmetric structure of elliptic curves implies that no signature is unique. A consequence of these “malleable” signatures is that they can be altered without being invalidated. For every set of parameters {r, s, v} used to create a signature, another distinct set {r', s', v'} results in an equivalent signature.

The potentially affected contracts are those that implement signature reuse or replay protection by marking the signature itself as used rather than the signed message or a nonce included in it. A user may take a signature that has already been submitted, submit it again in a different form, and bypass this protection.

The ecrecover EVM precompile allows for malleable (non-unique) signatures. OpenZeppelin’s ECDSA function rejects them by requiring the s value to be in the lower half order, and the v value to be either 27 or 28.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SafeVerifier is EIP712 {
    using ECDSA for bytes32;

    bytes32 private constant ACTION_TYPEHASH = keccak256(
        "Action(address target,uint256 value,uint256 nonce,uint256 deadline)"
    );

    mapping(address => uint256) public nonces;

    constructor() EIP712("SafeVerifier", "1") {}

    function execute(
        address target,
        uint256 value,
        uint256 deadline,
        bytes calldata signature  // ✅ Use bytes, not (v, r, s) — OZ handles parsing
    ) external {
        require(block.timestamp <= deadline, "Expired");

        uint256 nonce = nonces[msg.sender]++;

        bytes32 structHash = keccak256(abi.encode(
            ACTION_TYPEHASH,
            target,
            value,
            nonce,
            deadline
        ));

        // ✅ _hashTypedData wraps with \x19\x01 + domain separator
        bytes32 digest = _hashTypedData(structHash);

        // ✅ OZ ECDSA.recover: rejects address(0), enforces s in lower half
        address signer = digest.recover(signature);

        require(signer == msg.sender, "Invalid signer");

        // ... execute action
    }
}

When a smart contract system uses ecrecover directly instead of a well-known library like OpenZeppelin’s ECDSA, detecting and discarding malleable signatures is essential. Developers should not use signatures as unique identifiers; use hash invalidation or nonces for replay protection.


Cross-Contract Replay in Practice

Even with chain ID bound into the domain separator, a signature created for ContractA can be replayed against ContractB if both contracts share the same domain name, version, and chain ID but omit verifyingContract.

// ❌ VULNERABLE: Missing verifyingContract in domain
bytes32 BAD_DOMAIN = keccak256(abi.encode(
    DOMAIN_TYPE_HASH,
    keccak256("MyApp"),
    keccak256("1"),
    block.chainid
    // ← no address(this) !
));

// ✅ FIXED: verifyingContract binds to this specific instance
bytes32 GOOD_DOMAIN = keccak256(abi.encode(
    DOMAIN_TYPE_HASH,
    keccak256("MyApp"),
    keccak256("1"),
    block.chainid,
    address(this)   // ← unique per deployment
));

This matters critically for:

  • Multi-contract protocols where the same signer interacts with a registry, a vault, and a router under one brand name.
  • Proxy upgrades, where a new implementation deployed to a new address inherits the old domain if verifyingContract is absent.
  • Multi-sig wallets where a signed operation must not be valid across multiple wallet instances owned by the same signer.

EIP-2612 permit(): Common Implementation Errors

EIP-2612 adds three new functions to the ERC-20 standard: permit(), nonces(), and DOMAIN_SEPARATOR(). The bulk of the functionality lies in permit(), which takes the owner of the ERC-20 tokens, the account permitted to spend on behalf of the owner, the value to set the approval to, a deadline, and a signature.

EIP-2612 introduces built-in replay protection through the use of nonces and deadlines. In practice, however, the implementation is full of subtle traps.

Complete Correct Implementation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/**
 * @title ERC20Permit
 * @notice EIP-2612 permit() with all security checks shown explicitly.
 */
abstract contract ERC20Permit is ERC20, EIP712 {
    using ECDSA for bytes32;

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

    mapping(address => uint256) private _nonces;

    error PermitExpired();
    error InvalidSigner();
    error ZeroAddressOwner();

    constructor(string memory name)
        EIP712(name, "1")
    {}

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        // ✅ Check 1: Deadline enforcement — must be first
        if (block.timestamp > deadline) revert PermitExpired();

        // ✅ Check 2: Owner must not be the zero address
        // (ecrecover returns address(0) on failure; if owner == 0, it would pass)
        if (owner == address(0)) revert ZeroAddressOwner();

        // ✅ Check 3: Consume nonce atomically (pre-increment)
        uint256 currentNonce = _nonces[owner]++;

        // ✅ Check 4: Struct hash includes owner, spender, value, nonce, deadline
        bytes32 structHash = keccak256(abi.encode(
            PERMIT_TYPEHASH,
            owner,
            spender,
            value,
            currentNonce, // ← nonce BEFORE increment
            deadline
        ));

        // ✅ Check 5: EIP-712 digest (domain bound to chain + this contract)
        bytes32 digest = _hashTypedData(structHash);

        // ✅ Check 6: OZ ECDSA — rejects address(0), rejects malleable s-values
        address recoveredOwner = digest.recover(v, r, s);
        if (recoveredOwner != owner) revert InvalidSigner();

        // ✅ Effect: update allowance
        _approve(owner, spender, value);
    }

    function nonces(address owner) external view returns (uint256) {
        return _nonces[owner];
    }

    function DOMAIN_SEPARATOR() external view returns (bytes32) {
        return _domainSeparatorV4();
    }
}

Annotated Error Catalogue

According to EIP-2612, a call to permit(owner, spender, value, deadline, v, r, s) must meet all of the following conditions: the current block time must be less than or equal to the deadline; owner must not be the zero address; nonces[owner] before state update must equal nonce; and r, s, and v must be a valid secp256k1 signature from the owner. If any of these conditions are not met, the permit call must revert.

The following table maps the most common mistakes found in real audits:

#MistakeImpactFix
1Missing deadline checkSignature valid foreverrequire(block.timestamp <= deadline)
2Missing owner != address(0) checkAttacker can forge approval from zero addressExplicit zero-address guard or use OZ ECDSA
3Nonce checked but not incrementedSequential replay still possible_nonces[owner]++ inside the call
4Nonce incremented after external callReentrancy can re-enter before nonce bumpsIncrement before _approve
5Raw ecrecover without malleability guardAttacker morphs s to bypass hash deduplicationUse OZ ECDSA.recover
6verifyingContract omitted from domainSignature works on any contract with same nameAlways include address(this)
7Domain separator computed once at deploy and never updatedHard-fork chain ID change breaks signatures or creates confusionCache + lazy recompute on block.chainid mismatch
8permit() does not revert on invalid sig — it silently skipsDownstream logic sees stale approvalRequire explicit revert

You have to be careful about security issues and edge cases when using permits. You must prevent replay attacks by using nonces and validating chain IDs, and also handle expiration dates and invalid signatures gracefully.


The Zero Address Check — Standalone Deep Dive

This deserves its own section because it appears in almost every improperly-verified signature. The EVM ecrecover precompile does not throw on a malformed signature — it returns address(0).

As mentioned in EIP-2612 itself, the ecrecover precompile fails silently and returns the zero address as the signer on failure. This means that it is important to check that the signer is not the zero address when performing a permit(). Most of the well-known libraries account for this by throwing an error when ecrecover returns zero.

// ❌ ATTACK SCENARIO: Exploiting missing zero-address check

contract VulnerableGate {
    address public admin = address(0); // ← uninitialized admin

    function adminAction(
        bytes32 dataHash,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        address signer = ecrecover(dataHash, v, r, s);
        // If ecrecover fails → signer == address(0)
        // If admin == address(0) → passes!
        require(signer == admin, "Not admin");
        // ... privileged action
    }
}

// ✅ SAFE PATTERN: Belt-and-suspenders zero-address check
function safeAction(
    bytes32 digest,
    uint8 v, bytes32 r, bytes32 s
) external {
    address signer = ecrecover(digest, v, r, s);
    require(signer != address(0), "ecrecover failed");   // ← explicit
    require(signer == trustedSigner, "Wrong signer");
    // ...
}

// ✅ BEST PRACTICE: Use
 OpenZeppelin ECDSA
function verify(bytes32 hash, bytes memory signature) public pure returns (address) {
    return ECDSA.recover(hash, signature);
    // Reverts on: invalid length, s in upper half, v not 27/28
}

OpenZeppelin’s ECDSA.tryRecover returns (address, RecoverError) instead of reverting, which is useful when you want to handle invalid signatures gracefully rather than reverting the transaction.


The Signature Verification Checklist

Domain separator

  • chainId is included — prevents cross-chain replay
  • verifyingContract is address(this) — prevents cross-contract replay
  • name and version are included — human-readable binding
  • Domain separator is recomputed if chainId changes (relevant for chains that have forked)

Nonce management

  • Every signature includes a nonce bound to the signer
  • Nonce is incremented atomically on use, before any external calls
  • For unordered execution: bitmap nonces are used instead of sequential nonces
  • Nonce state is in the verifying contract, not an external contract that could be manipulated

Expiry

  • Every signature includes a deadline or expiry timestamp
  • block.timestamp <= deadline is enforced before signature verification
  • Deadline is set by the signer, not the relayer or submitter

ECDSA implementation

  • OpenZeppelin ECDSA.recover is used, not raw ecrecover
  • Zero-address check: recoveredAddress != address(0) before comparing to expected signer
  • No signatures are used as unique identifiers in mappings (malleability risk)

EIP-712 encoding

  • _hashTypedDataV4 or equivalent is used, not raw keccak256(structHash)
  • The TYPEHASH string exactly matches the struct field names and types, in order
  • Nested struct types are included in the type string

EIP-2612 permit()

  • deadline check is present
  • owner != address(0) check is present
  • nonces[owner] is incremented before the approval is applied
  • The PERMIT_TYPEHASH matches the standard exactly