IntermediateHigh Severity

Signature Replay Attacks

Understanding why signatures need nonces, chain IDs, and domain separators — and how to implement EIP-712 correctly to prevent replay attacks.

10 min readUpdated Jan 2025By Hexific Security Team
$100M+
Historical Losses
#6
Common Vuln Type
EIP-712
Standard Solution
100%
Preventable

What is a Signature Replay Attack?

A signature replay attack occurs when a valid cryptographic signature is used more than once to execute the same or similar action. In smart contracts, this typically happens when signatures lack proper uniqueness constraints like nonces, timestamps, or domain separators.

The Core Problem

Cryptographic signatures prove that someone authorized an action, but they don't inherently limit how many times that authorization can be used. Without explicit protections, one signature = unlimited uses.

🎯 Why Do Contracts Use Signatures?

⛽ Gasless Transactions

Meta-transactions let users sign messages off-chain while a relayer pays the gas fees (ERC-2771, ERC-4337).

✍️ Permit Functions

ERC-20 Permit (EIP-2612) allows token approvals via signature instead of a separate approve transaction.

🔐 Multi-Sig Wallets

Multiple owners sign off-chain, then one transaction executes with all signatures (Gnosis Safe).

📋 Off-Chain Orders

DEXs like 0x and Seaport use signed orders that are submitted on-chain when matched.

How Signatures Work in Ethereum

Understanding ECDSA signatures is key to understanding replay attacks:

🔑 ECDSA Signature Components

rX-coordinate of random point on curve (32 bytes)
sSignature proof value (32 bytes)
vRecovery ID (1 byte: 27 or 28)
SignatureVerification.sol
1// How signature verification works in Solidity
2function verifySignature(
3    bytes32 messageHash,
4    uint8 v,
5    bytes32 r,
6    bytes32 s
7) public pure returns (address signer) {
8    // Recover the signer's address from the signature
9    signer = ecrecover(messageHash, v, r, s);
10    
11    // ecrecover returns address(0) for invalid signatures
12    require(signer != address(0), "Invalid signature");
13    
14    return signer;
15}
16
17// The message hash must be prefixed for eth_sign compatibility
18bytes32 prefixedHash = keccak256(
19    abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
20);

Signature Malleability

ECDSA signatures have a malleability issue: for any valid (r, s), the pair (r, -s mod n) is also valid. Always use OpenZeppelin's ECDSA library which enforces s to be in the lower half of the curve order.

Types of Replay Attacks

🔄

1. Same-Contract Replay

The same signature is used multiple times on the same contract to repeat an action (e.g., claiming rewards twice).

Most CommonMissing Nonce
📋

2. Cross-Contract Replay

A signature meant for one contract is used on another contract with compatible verification logic.

Missing DomainFork Attacks
⛓️

3. Cross-Chain Replay

A signature from one blockchain is replayed on another chain (e.g., mainnet signature used on a fork or L2).

Missing Chain IDMulti-Chain Deploys

4. Delayed Replay

A signature is stored and used at a later time when conditions have changed (e.g., after price movements).

Missing DeadlineStale Orders

Same-Contract Replay Deep Dive

The most common replay attack occurs when a signature can be reused on the same contract because there's no nonce or state change that invalidates it.

1

User Signs Claim Message

User signs: "Claim 100 tokens for address 0xABC"

2

First Claim Succeeds ✓

Contract verifies signature, transfers 100 tokens

3

Attacker Replays Signature 💥

Same signature submitted again — still valid!

4

Second Claim Succeeds 💥

Another 100 tokens transferred — and again, and again...

Unlimited Claims

Without a nonce or used-signature tracking, the attacker can drain the entire token balance by replaying the same signature in a loop.

Cross-Contract Replay

When signatures don't include the contract address, they can be replayed on other contracts with similar verification logic.

📋 Cross-Contract Attack Scenario

Contract A (Original)
address: 0x1111...hash = keccak256(user, amount)
✓ Signature used here
Contract B (Attacker's Fork)
address: 0x2222...hash = keccak256(user, amount)
💥 Same signature works!
Contract address not in hash → Signature valid on both contracts

Cross-Chain Replay

After blockchain forks (like ETH/ETC split) or when deploying to multiple chains, signatures without chain IDs can be replayed across chains.

🔵

Ethereum

Chain ID: 1
Original signature
🟣

Polygon

Chain ID: 137
Replay possible! 💥
🔴

Arbitrum

Chain ID: 42161
Replay possible! 💥

Famous Example: Wintermute (2022)

After Optimism's OP token airdrop, Wintermute accidentally replayed their Ethereum transaction on Optimism. An attacker noticed and replayed 20M OP tokens meant for Wintermute to their own address.

20M OP LostCross-Chain

Real-World Examples

Sushiswap - Signature Replay

2021

A vulnerability in Sushiswap's Kashi lending platform allowed signatures to be replayed across different markets, enabling unauthorized borrows.

Cross-ContractLending

Polygon Plasma Bridge - $850M at Risk

2021

A critical vulnerability allowed signature replay on the Polygon Plasma bridge. Whitehat discovered it — could have drained $850M.

BridgeWhitehat Save

Optimism OP Token - 20M Tokens

2022

Wintermute's multi-sig setup on Optimism was exploited through a cross-chain replay attack during the OP airdrop.

Cross-ChainMulti-Sig

BadgerDAO - $120M Hack

2021

While primarily a frontend attack, stolen approval signatures were replayed to drain user wallets of $120M in assets.

Approvals$120M Lost

Vulnerable Code Patterns

❌ Pattern 1: No Nonce (Same-Contract Replay)

VulnerableClaim.sol
1// 🚨 VULNERABLE: No nonce to prevent replay
2contract VulnerableClaim {
3    mapping(address => bool) public claimed;  // Wrong approach!
4    
5    function claim(
6        uint256 amount,
7        bytes memory signature
8    ) external {
9        // ❌ Only checks if address claimed, not if signature was used
10        require(!claimed[msg.sender], "Already claimed");
11        
12        bytes32 hash = keccak256(abi.encodePacked(msg.sender, amount));
13        bytes32 ethHash = keccak256(
14            abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)
15        );
16        
17        address signer = recoverSigner(ethHash, signature);
18        require(signer == owner, "Invalid signature");
19        
20        claimed[msg.sender] = true;
21        
22        // 💥 Problem: User can claim again with DIFFERENT amount!
23        // Or attacker can use signature for msg.sender = contract
24        token.transfer(msg.sender, amount);
25    }
26}

❌ Pattern 2: No Domain Separator (Cross-Contract Replay)

VulnerablePermit.sol
1// 🚨 VULNERABLE: No contract address in hash
2contract VulnerablePermit {
3    function permit(
4        address spender,
5        uint256 amount,
6        uint256 deadline,
7        uint8 v, bytes32 r, bytes32 s
8    ) external {
9        require(block.timestamp <= deadline, "Expired");
10        
11        // ❌ No contract address, no chain ID!
12        bytes32 hash = keccak256(abi.encodePacked(
13            msg.sender,  // owner
14            spender,
15            amount,
16            deadline
17        ));
18        
19        address signer = ecrecover(hash, v, r, s);
20        require(signer == msg.sender, "Invalid sig");
21        
22        // 💥 This signature works on ANY contract with same logic!
23        allowance[msg.sender][spender] = amount;
24    }
25}

❌ Pattern 3: No Chain ID (Cross-Chain Replay)

VulnerableBridge.sol
1// 🚨 VULNERABLE: No chain ID protection
2contract VulnerableBridge {
3    function withdraw(
4        address to,
5        uint256 amount,
6        bytes memory signature
7    ) external {
8        // ❌ Same signature valid on mainnet, testnet, L2s, forks...
9        bytes32 hash = keccak256(abi.encodePacked(
10            to,
11            amount,
12            nonces[to]++  // Has nonce, but no chain ID!
13        ));
14        
15        require(verifySignature(hash, signature), "Invalid");
16        
17        // 💥 Deploy same contract on Polygon, replay mainnet signatures
18        payable(to).transfer(amount);
19    }
20}

❌ Pattern 4: No Deadline (Delayed Replay)

VulnerableSwap.sol
1// 🚨 VULNERABLE: Signature never expires
2contract VulnerableSwap {
3    function executeOrder(
4        address tokenIn,
5        address tokenOut,
6        uint256 amountIn,
7        uint256 minAmountOut,
8        bytes memory signature
9    ) external {
10        bytes32 hash = keccak256(abi.encodePacked(
11            msg.sender,
12            tokenIn,
13            tokenOut,
14            amountIn,
15            minAmountOut
16            // ❌ No deadline! Signature valid forever
17        ));
18        
19        require(verifySignature(hash, signature), "Invalid");
20        
21        // 💥 Signature can be held and executed when prices change
22        // User signed minAmountOut=100, prices dropped, they get 100 
23        // instead of current fair value of 150
24        _executeSwap(tokenIn, tokenOut, amountIn, minAmountOut);
25    }
26}

EIP-712: The Solution

EIP-712 is the standard for typed structured data signing. It prevents replay attacks by including a domain separator with contract address and chain ID.

🛡️ EIP-712 Domain Separator Components

nameHuman-readable contract name
versionContract version (e.g., "1")
chainIdPrevents cross-chain replay
verifyingContractPrevents cross-contract replay
salt(Optional) Additional uniqueness
EIP712.sol
1// EIP-712 Domain Separator Structure
2bytes32 constant DOMAIN_TYPEHASH = keccak256(
3    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
4);
5
6bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
7    DOMAIN_TYPEHASH,
8    keccak256(bytes("MyProtocol")),     // name
9    keccak256(bytes("1")),               // version
10    block.chainid,                       // chainId (prevents cross-chain)
11    address(this)                        // verifyingContract (prevents cross-contract)
12));
13
14// The final hash to sign
15bytes32 digest = keccak256(abi.encodePacked(
16    "\x19\x01",           // EIP-712 prefix
17    DOMAIN_SEPARATOR,       // Domain-specific data
18    structHash              // The actual message struct hash
19));

Secure Implementation

✅ Complete EIP-712 Implementation

SecurePermit.sol
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.19;
3
4import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
5import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
6
7contract SecurePermit is EIP712 {
8    using ECDSA for bytes32;
9    
10    // ✅ Nonces prevent same-contract replay
11    mapping(address => uint256) public nonces;
12    mapping(address => mapping(address => uint256)) public allowance;
13    
14    // ✅ Type hash for the Permit struct
15    bytes32 public constant PERMIT_TYPEHASH = keccak256(
16        "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
17    );
18    
19    constructor() EIP712("SecurePermit", "1") {}
20    
21    function permit(
22        address owner,
23        address spender,
24        uint256 value,
25        uint256 deadline,
26        uint8 v,
27        bytes32 r,
28        bytes32 s
29    ) external {
30        // ✅ Check deadline (prevents delayed replay)
31        require(block.timestamp <= deadline, "Permit expired");
32        
33        // ✅ Build struct hash with nonce
34        bytes32 structHash = keccak256(abi.encode(
35            PERMIT_TYPEHASH,
36            owner,
37            spender,
38            value,
39            nonces[owner]++,  // ✅ Increment nonce (prevents same-contract replay)
40            deadline
41        ));
42        
43        // ✅ EIP-712 digest includes domain separator (chain ID + contract address)
44        bytes32 digest = _hashTypedDataV4(structHash);
45        
46        // ✅ Recover and verify signer
47        address signer = ECDSA.recover(digest, v, r, s);
48        require(signer == owner, "Invalid signature");
49        
50        // Execute the permit
51        allowance[owner][spender] = value;
52        
53        emit Approval(owner, spender, value);
54    }
55    
56    // ✅ Expose domain separator for frontend
57    function DOMAIN_SEPARATOR() external view returns (bytes32) {
58        return _domainSeparatorV4();
59    }
60    
61    event Approval(address indexed owner, address indexed spender, uint256 value);
62}

✅ Frontend Signing with ethers.js

signPermit.ts
1import { ethers } from 'ethers';
2
3async function signPermit(
4  signer: ethers.Signer,
5  contractAddress: string,
6  spender: string,
7  value: bigint,
8  nonce: bigint,
9  deadline: bigint
10) {
11  const domain = {
12    name: "SecurePermit",
13    version: "1",
14    chainId: await signer.provider!.getNetwork().then(n => n.chainId),
15    verifyingContract: contractAddress
16  };
17  
18  const types = {
19    Permit: [
20      { name: "owner", type: "address" },
21      { name: "spender", type: "address" },
22      { name: "value", type: "uint256" },
23      { name: "nonce", type: "uint256" },
24      { name: "deadline", type: "uint256" }
25    ]
26  };
27  
28  const message = {
29    owner: await signer.getAddress(),
30    spender,
31    value,
32    nonce,
33    deadline
34  };
35  
36  // EIP-712 typed data signing
37  const signature = await signer.signTypedData(domain, types, message);
38  
39  // Split signature for contract
40  const { v, r, s } = ethers.Signature.from(signature);
41  
42  return { v, r, s };
43}

✅ Alternative: Used Signature Tracking

SecureClaim.sol
1// ✅ SECURE: Track used signatures (alternative to nonces)
2contract SecureClaim {
3    mapping(bytes32 => bool) public usedSignatures;
4    
5    function claim(
6        uint256 amount,
7        uint256 deadline,
8        bytes memory signature
9    ) external {
10        require(block.timestamp <= deadline, "Expired");
11        
12        // Create unique hash including all parameters
13        bytes32 hash = keccak256(abi.encodePacked(
14            "\x19\x01",
15            DOMAIN_SEPARATOR,
16            keccak256(abi.encode(
17                CLAIM_TYPEHASH,
18                msg.sender,
19                amount,
20                deadline
21            ))
22        ));
23        
24        // ✅ Check if signature was already used
25        require(!usedSignatures[hash], "Signature already used");
26        
27        // Verify signature
28        address signer = ECDSA.recover(hash, signature);
29        require(signer == trustedSigner, "Invalid signature");
30        
31        // ✅ Mark signature as used BEFORE transfer (CEI pattern)
32        usedSignatures[hash] = true;
33        
34        token.transfer(msg.sender, amount);
35    }
36}

Testing for Replay Vulnerabilities

ReplayAttackTest.t.sol
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.19;
3
4import "forge-std/Test.sol";
5import "../src/SecurePermit.sol";
6
7contract ReplayAttackTest is Test {
8    SecurePermit permit;
9    uint256 ownerKey = 0x1234;
10    address owner;
11    address spender = makeAddr("spender");
12    
13    function setUp() public {
14        permit = new SecurePermit();
15        owner = vm.addr(ownerKey);
16    }
17    
18    // ✅ Test: Same signature cannot be reused
19    function test_RevertWhen_SignatureReplayed() public {
20        uint256 deadline = block.timestamp + 1 hours;
21        uint256 nonce = permit.nonces(owner);
22        
23        // Create valid signature
24        bytes32 digest = _getDigest(owner, spender, 100, nonce, deadline);
25        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest);
26        
27        // First use succeeds
28        permit.permit(owner, spender, 100, deadline, v, r, s);
29        
30        // ✅ Replay attempt should fail
31        vm.expectRevert("Invalid signature");
32        permit.permit(owner, spender, 100, deadline, v, r, s);
33    }
34    
35    // ✅ Test: Expired signatures rejected
36    function test_RevertWhen_SignatureExpired() public {
37        uint256 deadline = block.timestamp + 1 hours;
38        uint256 nonce = permit.nonces(owner);
39        
40        bytes32 digest = _getDigest(owner, spender, 100, nonce, deadline);
41        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest);
42        
43        // Fast forward past deadline
44        vm.warp(deadline + 1);
45        
46        vm.expectRevert("Permit expired");
47        permit.permit(owner, spender, 100, deadline, v, r, s);
48    }
49    
50    // ✅ Test: Cross-chain replay protection
51    function test_CrossChainReplayPrevented() public {
52        uint256 deadline = block.timestamp + 1 hours;
53        
54        // Deploy on "mainnet" (chain 1)
55        vm.chainId(1);
56        SecurePermit mainnetPermit = new SecurePermit();
57        
58        // Create signature for mainnet
59        bytes32 mainnetDigest = _getDigest(
60            mainnetPermit, owner, spender, 100, 0, deadline
61        );
62        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, mainnetDigest);
63        
64        // Switch to "polygon" (chain 137)
65        vm.chainId(137);
66        SecurePermit polygonPermit = new SecurePermit();
67        
68        // ✅ Mainnet signature should fail on Polygon
69        vm.expectRevert("Invalid signature");
70        polygonPermit.permit(owner, spender, 100, deadline, v, r, s);
71    }
72    
73    // ✅ Fuzz test: Nonces always increment correctly
74    function testFuzz_NonceIncrementsCorrectly(uint8 numPermits) public {
75        vm.assume(numPermits > 0 && numPermits < 50);
76        
77        for (uint i = 0; i < numPermits; i++) {
78            uint256 expectedNonce = i;
79            assertEq(permit.nonces(owner), expectedNonce);
80            
81            uint256 deadline = block.timestamp + 1 hours;
82            bytes32 digest = _getDigest(owner, spender, 100, expectedNonce, deadline);
83            (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest);
84            
85            permit.permit(owner, spender, 100, deadline, v, r, s);
86            
87            // Nonce should have incremented
88            assertEq(permit.nonces(owner), expectedNonce + 1);
89        }
90    }
91    
92    function _getDigest(
93        address _owner,
94        address _spender,
95        uint256 _value,
96        uint256 _nonce,
97        uint256 _deadline
98    ) internal view returns (bytes32) {
99        return _getDigest(permit, _owner, _spender, _value, _nonce, _deadline);
100    }
101    
102    function _getDigest(
103        SecurePermit _permit,
104        address _owner,
105        address _spender,
106        uint256 _value,
107        uint256 _nonce,
108        uint256 _deadline
109    ) internal view returns (bytes32) {
110        bytes32 structHash = keccak256(abi.encode(
111            _permit.PERMIT_TYPEHASH(),
112            _owner,
113            _spender,
114            _value,
115            _nonce,
116            _deadline
117        ));
118        
119        return keccak256(abi.encodePacked(
120            "\x19\x01",
121            _permit.DOMAIN_SEPARATOR(),
122            structHash
123        ));
124    }
125}

🛠️ Signature Security Tools

Slither Detectors
slither . --detect erc20-indexed,arbitrary-send-erc20
OpenZeppelin

Use EIP712.sol and ECDSA.sol utilities

eth-sig-util

JavaScript library for EIP-712 signing

Foundry
vm.sign(privateKey, digest)

✅ Signature Security Checklist

Using EIP-712 typed data signing
Domain separator includes chain ID
Domain separator includes contract address
Nonce incremented for each signature
OR: Used signatures tracked in mapping
Deadline/expiry included in signed data
Using OpenZeppelin ECDSA library
Signature malleability handled (s in lower half)
Testing replay on same contract
Testing cross-chain replay scenarios

📚 Quick Reference

🚫 Must Include

  • • Nonce (per-user counter)
  • • Chain ID
  • • Contract address
  • • Deadline/expiry

✅ Use These

  • • OpenZeppelin EIP712
  • • OpenZeppelin ECDSA
  • • signTypedData (ethers)
  • • eth_signTypedData_v4

⚠️ Common Mistakes

  • • Plain ecrecover (no lib)
  • • No domain separator
  • • Reusing signatures
  • • No deadline check