VRF7
HomeGuides › Signature Replay Attacks and EIP-712

Signature Replay Attacks and EIP-712

Updated 2026-06-18 · VRF7 Security Guides

A signature replay attack occurs when a valid cryptographic signature is reused in a context the original signer never intended. In Ethereum smart contracts, this is surprisingly easy to trigger. If your contract verifies an off-chain signature without tracking whether that signature has already been consumed, an attacker can copy it from a past transaction and submit it again—potentially draining funds, escalating privileges, or executing arbitrary state changes on the victim's behalf. This guide covers how replay vulnerabilities arise, how EIP-712 structured typed data mitigates them, and where developers still go wrong even after adopting EIP-712.

How ecrecover Works and Where It Goes Wrong

ecrecover is the Ethereum precompile that recovers the address that produced an ECDSA signature. Given a message hash and a signature (v, r, s), it returns the signing address. Contracts use this to let users authorize actions off-chain and submit the authorization on-chain later, saving the user a transaction fee or enabling meta-transactions.

The vulnerability emerges from what ecrecover does not do: it has no memory. It does not know whether this exact signature was used five blocks ago. It does not know which contract it is being called on, which chain it is running on, or what the intended expiry was. That information must be encoded into the message hash explicitly, and the contract must enforce it.

A minimal vulnerable pattern looks like this:

// VULNERABLE: no nonce, no domain, no expiry
function execute(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external {
    address signer = ecrecover(hash, v, r, s);
    require(signer == owner, "Not owner");
    // perform privileged action
}

An attacker who sees this transaction on-chain can call execute again with the identical arguments. The signature validates every time because nothing in the contract's state or the message hash changes between calls.

The Three Missing Protections

1. Nonces

A nonce is a per-signer counter that increments each time a signature is consumed. The signer includes the current nonce in the signed message. On-chain, the contract checks that the nonce matches what it has stored, then increments it. Any attempt to resubmit the same signature will fail because the stored nonce has already moved forward.

mapping(address => uint256) public nonces;

function execute(bytes32 dataHash, uint256 nonce, uint8 v, bytes32 r, bytes32 s) external {
    bytes32 hash = keccak256(abi.encodePacked(dataHash, nonce));
    address signer = ecrecover(hash, v, r, s);
    require(signer == owner, "Not owner");
    require(nonces[owner] == nonce, "Invalid nonce");
    nonces[owner]++;
    // perform privileged action
}

This eliminates same-chain replay within a single contract, but it is still not enough on its own.

2. Domain Separators

Without a domain separator, a valid signature for Contract A on Mainnet can be replayed against Contract B with the same interface on the same chain, or against Contract A deployed on a testnet that shares the chain ID with another network in some edge case. A domain separator encodes the contract's identity into every signed message:

Cross-chain replay is a particularly dangerous variant. After a hard fork, both chains may share historical signatures unless chainId is included in the signed data. The 2016 Ethereum/Ethereum Classic split is the canonical example, but the same risk applies any time a contract is deployed at the same address on multiple chains.

3. Expiry Timestamps

Even with a nonce, a signature that was never submitted remains valid indefinitely unless the message includes a deadline. Adding a deadline field and requiring block.timestamp <= deadline limits the window an attacker has to front-run or misuse a captured signature. For context on front-running risks more broadly, see our guide on Front-Running and MEV in Smart Contracts.

EIP-712: Structured Typed Data

EIP-712 is the Ethereum standard that formalizes all three protections into a composable, human-readable framework. It defines how to hash structured data in a way that wallets can display the fields being signed rather than showing users an opaque 32-byte blob.

Every EIP-712 implementation starts with a domain separator computed at deployment:

bytes32 public immutable DOMAIN_SEPARATOR;

constructor() {
    DOMAIN_SEPARATOR = keccak256(
        abi.encode(
            keccak256(
                "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
            ),
            keccak256(bytes("MyProtocol")),
            keccak256(bytes("1")),
            block.chainid,
            address(this)
        )
    );
}

Then each message type has its own type hash and struct hash, and the final signed hash is:

bytes32 digest = keccak256(
    abi.encodePacked(
        "\x19\x01",
        DOMAIN_SEPARATOR,
        structHash
    )
);

The \x19\x01 prefix is specified by EIP-191 and prevents the hash from colliding with a raw transaction hash, closing another class of replay involving transaction-level signatures.

permit() and Its Common Pitfalls

ERC-20's permit() function (EIP-2612) is the most widely deployed application of EIP-712. It lets a user sign an off-chain approval so a relayer or protocol can call permit() on their behalf, avoiding a separate approve transaction. OpenZeppelin's implementation is used by tokens like DAI (with a slightly different domain), USDC, and WETH on some chains.

Despite its prevalence, permit() introduces several subtle risks:

Griefing via Front-Running permit()

Because permit() is public, a griefing attack is possible: an observer front-runs the legitimate caller by submitting the same permit signature directly to the token contract first. The nonce increments, and the legitimate caller's bundled permit()+action() transaction reverts on the permit step. Protocols that rely on permit() should wrap it in a try/catch or check the allowance after the call, falling back gracefully if the permit was already consumed.

// Safer pattern: use try/catch around permit
try IERC20Permit(token).permit(owner, address(this), amount, deadline, v, r, s) {}
catch {}
// Proceed if allowance is already sufficient
require(IERC20(token).allowance(owner, address(this)) >= amount, "Insufficient allowance");

DAI-Style Domain Separators Without chainId

Original DAI's domain separator encodes the chain ID at construction time using a hardcoded value rather than block.chainid. If DAI is used on a chain that later forks, the domain separator does not update automatically. Newer implementations should use block.chainid directly, or recompute the domain separator dynamically if the chain ID may change (relevant for counterfactual deployments).

Reuse Across Multiple Contracts

A common mistake is building a meta-transaction relayer that accepts permit signatures intended for a specific token but does not verify the verifyingContract field. An attacker could craft a scenario where a signature for Token A is replayed against a different contract that accepts the same struct layout. The verifying contract address in the domain separator exists precisely to prevent this.

Identifying Replay Vulnerabilities in Your Codebase

When auditing for signature replay attack vectors, look for these patterns:

Access control logic that relies on signed messages is particularly high-risk because a replay can elevate an attacker to an authorized role. For more on how signature-based access patterns interact with role systems, see our guide on Access Control Vulnerabilities in Smart Contracts.

A Correct EIP-712 Pattern

The following shows a minimal but complete EIP-712 transfer authorization, including nonce, domain, and expiry:

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

function executeTransfer(
    address to,
    uint256 amount,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external {
    require(block.timestamp <= deadline, "Signature expired");

    bytes32 structHash = keccak256(
        abi.encode(TRANSFER_TYPEHASH, to, amount, nonces[msg.sender], deadline)
    );

    bytes32 digest = keccak256(
        abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
    );

    address signer = ecrecover(digest, v, r, s);
    require(signer != address(0) && signer == authorizedSigner, "Invalid signature");

    nonces[signer]++;
    _transfer(signer, to, amount);
}

Note the explicit check that signer != address(0). ecrecover returns the zero address on malformed input rather than reverting; if your authorized signer mapping ever contains the zero address for any reason, this check is the only thing standing between you and a trivially exploitable contract.

Testing and Automated Detection

Static analysis tools can flag many of these issues automatically. Slither has detectors for missing zero-address checks on ecrecover and for state variables that look like nonces but are never incremented. Mythril's symbolic execution can trace paths where the same external call succeeds multiple times with identical calldata, which is a strong indicator of missing replay protection.

If you want to check your contracts quickly, you can run an automated scan with VRF7, which runs Slither, Mythril, and several other tools in parallel and explains every finding in plain language—useful for catching the structural issues before a more thorough review.

Signature-based authorization is powerful and gas-efficient, but every component of replay protection—nonce, domain separator, expiry, and zero-address guard—must be present. Omitting any one of them can be enough for an attacker to extract value that users never intended to authorize.

Scan your contract before you ship

Run an automated, transparent security scan — seven industry tools in parallel, every finding labeled with its source tool. It is not a substitute for a full manual audit, but it is a fast first line of defense.

Scan a contract

Frequently asked questions

What is a signature replay attack in Solidity?

A signature replay attack happens when an attacker resubmits a previously valid off-chain signature to a smart contract. If the contract does not track which signatures have been used (via nonces) or bind signatures to a specific contract and chain (via a domain separator), the same signature can authorize repeated or unintended actions.

Why is a nonce not enough to prevent all replay attacks?

A nonce prevents the same signature from being used twice within a single contract, but it does not stop cross-chain replay or cross-contract replay. If the signed message does not include the chain ID and the verifying contract's address, a valid signature on one chain or contract can be replayed on another that shares the same interface.

What does EIP-712 add over plain ecrecover?

EIP-712 standardizes how structured data is hashed before signing. It mandates a domain separator that encodes the contract name, version, chain ID, and contract address, ensuring signatures are tightly bound to a specific deployment. It also defines a typed data format that wallets can display to users in human-readable form, reducing blind-signing risks.

Can permit() be exploited even when EIP-712 is used correctly?

Yes. The most common permit() exploit is a griefing attack: an observer front-runs the legitimate user by submitting the permit signature directly to the token contract first, consuming the nonce. The user's bundled transaction then reverts. Protocols should wrap permit() calls in try/catch and verify the resulting allowance rather than assuming the permit call will succeed.

Why must I check that ecrecover does not return the zero address?

ecrecover returns address(0) when the signature is malformed or the input is invalid, rather than reverting. If your contract checks the recovered address against a stored signer without guarding against zero, and if the zero address appears in any role or allowlist, a crafted invalid signature could pass the check. Always require that the recovered address is non-zero before comparing it to any expected signer.