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
🎯 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)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
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).
2. Cross-Contract Replay
A signature meant for one contract is used on another contract with compatible verification logic.
3. Cross-Chain Replay
A signature from one blockchain is replayed on another chain (e.g., mainnet signature used on a fork or L2).
4. Delayed Replay
A signature is stored and used at a later time when conditions have changed (e.g., after price movements).
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.
User Signs Claim Message
User signs: "Claim 100 tokens for address 0xABC"
First Claim Succeeds ✓
Contract verifies signature, transfers 100 tokens
Attacker Replays Signature 💥
Same signature submitted again — still valid!
Second Claim Succeeds 💥
Another 100 tokens transferred — and again, and again...
Unlimited Claims
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)Contract B (Attacker's Fork)
address: 0x2222...hash = keccak256(user, amount)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: 1Polygon
Chain ID: 137Arbitrum
Chain ID: 42161Famous 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.
Real-World Examples
Sushiswap - Signature Replay
2021A vulnerability in Sushiswap's Kashi lending platform allowed signatures to be replayed across different markets, enabling unauthorized borrows.
Polygon Plasma Bridge - $850M at Risk
2021A critical vulnerability allowed signature replay on the Polygon Plasma bridge. Whitehat discovered it — could have drained $850M.
Optimism OP Token - 20M Tokens
2022Wintermute's multi-sig setup on Optimism was exploited through a cross-chain replay attack during the OP airdrop.
BadgerDAO - $120M Hack
2021While primarily a frontend attack, stolen approval signatures were replayed to drain user wallets of $120M in assets.
Vulnerable Code Patterns
❌ Pattern 1: No Nonce (Same-Contract Replay)
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)
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)
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)
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 nameversionContract version (e.g., "1")chainIdPrevents cross-chain replayverifyingContractPrevents cross-contract replaysalt(Optional) Additional uniqueness1// 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
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
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
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
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-erc20OpenZeppelin
Use EIP712.sol and ECDSA.sol utilities
eth-sig-util
JavaScript library for EIP-712 signing
Foundry
vm.sign(privateKey, digest)✅ Signature Security Checklist
📚 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