Replay Attacks
How the attack works and how to prevent it
Replay Attacks in Solidity: How Hackers Reuse Signatures to Drain Smart Contracts
A smart contract replay attack reuses a valid signature or transaction to execute an action more times, on more contracts, or on more chains than the signer intended.
Replay attacks have caused losses involving 20 million OP tokens and 40,000 ETC, while researchers have identified replay weaknesses in more than 1,700 contracts. Contracts that verify signatures need an explicit way to bind each authorization to its intended context and mark it as consumed.
This guide explains same-chain, cross-contract, and cross-chain replay, then shows how EIP-712 domain separation, nonces, deadlines, and standard signature libraries address each case.
What exactly is a replay attack?
A replay attack happens when a valid digital signature or transaction is captured and resubmitted. A vulnerable contract may execute the same action multiple times, or accept it on a different contract or chain, without a new authorization from the signer.
In Solidity, a common cause is signature verification with ecrecover without a nonce or other consumption record. The attacker does not forge a signature; they reuse one that already verifies.
The concert ticket analogy
Think of a replay attack like entering a concert with a single-use ticket. Normally, the security guard scans the ticket, marks it "used," and admits the holder. If the scanner never records its use, the same ticket remains valid.
The same ticket could be passed to another person and scanned again. Replay protection is the equivalent of marking that ticket as used and checking that it belongs to this venue and event.
Why replay protection matters
Replay vulnerabilities have affected token distributions, exchanges, bridges, and application-level signature systems:
| Attack | Year | Impact |
|---|---|---|
| Optimism OP Token Theft | 2022 | 20M OP tokens (~$17.6M) stolen via cross-chain replay |
| ETH/ETC Chain Split | 2016 | 40,000+ ETC drained from exchanges |
| ETHW Omni Bridge | 2022 | 200 ETHW stolen + 37% token price crash |
| Nomad Bridge | 2022 | $190M drained (copy-paste replay exploit) |
The 1,739 figure in the stats above comes from a 2025 academic study of 15,383 signature-verification contracts, which put roughly $4.76 million of active assets behind the vulnerable ones. The study is not named in the reporting that circulated it, so treat the split as indicative rather than a citable measurement.
What makes replay attacks unique
Unlike reentrancy, replay attacks exploit the scope and lifecycle of valid authorization data. The signature is legitimate, but the signer did not authorize its reuse. Audits must therefore check both signature validity and the context in which that signature remains valid.
The Optimism OP token theft
The 2022 Optimism incident shows how a transaction created for one chain can be reused on another when its signature does not include a chain identifier.
The setup
In May 2022, the Optimism Foundation allocated 20 million OP tokens to market maker Wintermute. Wintermute supplied a Gnosis Safe multisig address that existed on Ethereum L1 but had not been deployed on Optimism L2.
The Optimism team sent the tokens in three transactions. The funds arrived at an address that Wintermute did not yet control on Optimism.
The attack: June 5, 2022
The original transactions that deployed the Gnosis Safe proxy factory on Ethereum L1 predated EIP-155, so their signatures did not include chainId.
Attack sequence:
| Step | Attacker action | Why it worked |
|---|---|---|
| 1 | Retrieved the Gnosis Safe factory deployment transaction from Ethereum mainnet | The legacy transaction did not include a chainId. |
| 2 | Submitted the raw deployment transaction on Optimism | The same bytecode and transaction data produced the same deployment. |
| 3 | Called the factory until it reached the required nonce | Deterministic CREATE operations derive addresses from the deployer and nonce. |
| 4 | Created the target multisig address on Optimism | The attacker controlled the newly created address. |
| 5 | Transferred the 20M OP tokens | Control of the address allowed the attacker to move its tokens. |
The attacker did not break the signature scheme or the Optimism network. They replayed a valid Ethereum transaction on another chain because the legacy signature was not bound to a chainId.
The aftermath
The attacker returned 17 million OP tokens and retained 2 million, worth around $1.4 million at the time. The incident reinforced the need to verify replay protection during cross-chain deployments.
How replay attacks work: a step-by-step breakdown
The following sequence shows a same-chain signature replay against a contract with no nonce tracking.
The exploit flow
| Step | What Happens | The State of the Contract |
|---|---|---|
| 1 | The owner signs a message saying: "Send 1 ETH to Bob" | The signature is valid. |
| 2 | A relayer submits the signature to the contract | The contract verifies it and sends 1 ETH. |
| 3 | The attacker copies the signature from public calldata | The signature remains valid because no nonce was consumed. |
| 4 | The attacker resubmits the same signature | The contract verifies it and sends another 1 ETH. |
| 5 | The attacker repeats the call | The contract has no record that the authorization was already used. |
The attack phases
The attacker watches for transactions that include off-chain signatures - such as meta-transactions, permit approvals, or gasless transfers. These signatures are publicly visible in calldata.
The attacker extracts the signature and the signed message parameters from the transaction calldata. Since everything is on-chain, this requires zero special access.
The attacker 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 attacker loops until funds are drained or the desired action is exhausted.
Replay attack variants
Replay variants correspond to different missing fields or state checks in signature verification.
Two additional implementation errors are worth checking:
Signature Malleability Replay - ECDSA can admit a second valid tuple (v', r, n-s) for a signature (v, r, s) unless low-s values are enforced. A contract that tracks only raw signature bytes may therefore treat the alternate representation as unused. Standard libraries such as OpenZeppelin ECDSA reject malleable signatures.
Incorrect consumption state - A custom mapping(bytes32 => bool) usedSignatures does not help if the contract fails to set the value before an external interaction, derives the wrong key, or updates a different storage entry.

Vulnerable signature vault
This example uses a signature-based withdrawal without nonce protection.
This contract is unsafe by design. Do not copy-paste it into your project.
The vulnerable signature verification
// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
contract VulnerableVault {
address public owner;
constructor() payable {
owner = msg.sender;
}
// Anyone can call this with a valid owner signature
function withdraw(address _to, uint256 _amount, bytes memory _sig) external {
// Build message hash - MISSING: nonce, chainId, contract address
bytes32 msgHash = keccak256(abi.encodePacked(_to, _amount));
bytes32 ethHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", msgHash)
);
// Recover signer from signature
(uint8 v, bytes32 r, bytes32 s) = splitSignature(_sig);
address signer = ecrecover(ethHash, v, r, s);
require(signer == owner, "Invalid signature");
// Transfer funds - no record that this signature was used!
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Transfer failed");
}
function splitSignature(bytes memory sig) internal pure
returns (uint8, bytes32, bytes32)
{
require(sig.length == 65, "Invalid sig length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
return (v, r, s);
}
receive() external payable {}
}
Why is this vulnerable?
The problem is what is missing from the signed message:
-
No nonce - same signature works unlimited times on this contract
-
No
block.chainid- signature works on every EVM chain -
No
address(this)- signature works on every copy of this contract -
No deadline - signature is valid forever
-
Raw
ecrecover- doesn't reject malleable signatures
The owner signs "Send 1 ETH to Alice" once. That signature can drain the entire vault.
Replay attack exploit walkthrough
Here's how an attacker exploits the vulnerable vault above.
// ATTACKER CONTRACT - Educational purposes only
interface IVulnerableVault {
function withdraw(address _to, uint256 _amount, bytes memory _sig) external;
}
contract ReplayAttacker {
IVulnerableVault public target;
constructor(address _target) {
target = IVulnerableVault(_target);
}
// Attacker calls this with a signature they observed on-chain
function drainVault(
address _to,
uint256 _amount,
bytes memory _capturedSig
) external {
// Replay the same valid signature until vault is empty
while (address(target).balance >= _amount) {
target.withdraw(_to, _amount, _capturedSig);
}
}
}
Exploit sequence
-
Observe the transaction - The attacker finds a legitimate
withdraw()call in the mempool or transaction history. -
Copy the calldata - They extract
_to,_amount, and_sigfrom public calldata. -
Resubmit the signature - The attacker calls
withdraw()again with the same parameters and signature. -
Repeat while valid - The contract continues to verify the signature because it never records its consumption.
The attacker does not need the private key or a forged signature. The exploit reuses an authorization that the contract failed to make single-use.
{
"title": "π¬ Signature replay: one valid signature drains the whole vault",
"stage": { "width": 860, "height": 400 },
"nodes": [
{ "id": "vault", "label": "The Vault", "role": "verifies via ecrecover", "emoji": "π¦", "x": 345, "y": 40, "color": "cyan" },
{ "id": "owner", "label": "Owner EOA", "role": "signs once, off-chain", "emoji": "π§βπΌ", "x": 60, "y": 250, "color": "green" },
{ "id": "attacker", "label": "Attacker", "role": "scrapes the signature", "emoji": "π§βπ»", "x": 630, "y": 250, "color": "red" }
],
"links": [
{ "from": "owner", "to": "vault" },
{ "from": "attacker", "to": "vault" },
{ "from": "vault", "to": "attacker" }
],
"nets": [
{ "id": "vault", "label": "Vault Balance" },
{ "id": "atk", "label": "Attacker Take" }
],
"legend": [
{ "cls": "call", "label": "contract call" },
{ "cls": "token", "label": "ETH transfer" },
{ "cls": "sig", "label": "signature / state write" },
{ "cls": "fail", "label": "reverted / blocked" }
],
"scenarios": {
"Vulnerable (no nonce)": [
{ "note": "The owner signs <b>\"withdraw 1 ETH\"</b> and the relayer posts it once. The vault verifies the signature and pays out as authorized.", "hi": ["owner", "vault"], "bal": { "vault": "10 ETH", "atk": "0" }, "net": { "vault": "9 ETH", "atk": "0" }, "chip": { "from": "owner", "to": "vault", "label": "withdraw 1 ETH (sig)", "cls": "sig" } },
{ "note": "The signature sits in public calldata. The attacker <b>copies it</b>. It remains valid because nothing recorded that it was used.", "hi": ["attacker"], "chip": { "from": "attacker", "to": "vault", "label": "replay same sig", "cls": "call" } },
{ "note": "The vault re-runs <b>ecrecover</b>, sees the owner's valid signature, and sends <b>another 1 ETH</b>, this time to the attacker.", "tone": "bad", "hi": ["vault", "attacker"], "bal": { "vault": "8 ETH", "atk": "2 ETH" }, "net": { "vault": "8 ETH", "atk": "2 ETH" }, "chip": { "from": "vault", "to": "attacker", "label": "1 ETH", "cls": "token" } },
{ "note": "Nothing marks the signature as spent, so the attacker just loops the replay until the vault hits <b>zero</b>.", "tone": "bad", "hi": ["vault", "attacker"], "bal": { "vault": "0 ETH", "atk": "10 ETH" }, "net": { "vault": "0 ETH", "atk": "10 ETH" }, "chip": { "from": "vault", "to": "attacker", "label": "β¦drained", "cls": "token" } }
],
"Fixed (EIP-712 + nonce)": [
{ "note": "The owner signs an EIP-712 message that bundles a <b>nonce</b>, the <b>chainId</b> and the vault <b>address</b>. The first withdrawal consumes nonce #0.", "hi": ["owner", "vault"], "bal": { "vault": "10 ETH", "atk": "0" }, "net": { "vault": "9 ETH", "atk": "0" }, "chip": { "from": "owner", "to": "vault", "label": "withdraw (nonce 0)", "cls": "sig" } },
{ "note": "The attacker replays the identical signature. The vault now expects <b>nonce #1</b>, so the recovered message no longer matches and the call <b>reverts</b>.", "tone": "ok", "hi": ["attacker", "vault"], "chip": { "from": "attacker", "to": "vault", "label": "replay β revert β", "cls": "fail" } },
{ "note": "One signature equals exactly <b>one</b> withdrawal. The drain is impossible.", "tone": "ok", "hi": ["vault"], "net": { "vault": "9 ETH", "atk": "0" } }
]
}
}
How to prevent replay attacks
Multiple layers of defense work together to make signatures single-use and context-bound.
What it does: Binds every signature to a specific contract name, version, chainId, and verifying contract address. This single mechanism prevents both cross-chain and cross-contract replay.
When to use: Every contract that verifies off-chain signatures. This is the industry standard.
Limitation: Must recalculate the domain separator on chain forks (don't cache block.chainid at deployment). OpenZeppelin's EIP712 contract handles this automatically.
What it does: Assigns an incrementing counter to each signer. Every signature includes the current nonce value. After use, the nonce increments, invalidating the old signature permanently.
When to use: Signature-based operations that should be single-use. OpenZeppelin's Nonces contract provides a reusable implementation.
Limitation: Sequential nonces require in-order execution. For unordered execution, consider Uniswap's Permit2 bitmap nonce pattern.
What it does: Adds a deadline timestamp to the signed message. The contract rejects signatures where block.timestamp > deadline.
When to use: As an additional layer alongside nonces. Especially important for permit/approval signatures where stale signatures can be exploited at the worst possible moment.
Limitation: Not sufficient alone - an attacker can still replay within the deadline window. Always combine with nonces.
What it does: Maintains a mapping(bytes32 => bool) of used message hashes. After verification, the hash is marked as consumed.
When to use: Simple one-time-use signature systems (e.g., NFT whitelist minting).
Limitation: Tracking by signature bytes instead of message hash is vulnerable to signature malleability. Always track by the message hash, never by the raw signature bytes. Nonces are generally preferred.
EIP-712 domain separation, nonces, and deadlines address the replay variants covered here. OpenZeppelin provides reusable components, but the protocol must still define who may sign, what may be authorized, and how upgrades affect the domain.

Replay attack secure code example
The following example uses OpenZeppelin's EIP712 and Nonces contracts. Production deployments still need protocol-specific authorization rules, upgrade planning, test coverage, and review of signer and nonce lifecycle assumptions.
// SECURE CONTRACT - EIP-712 and nonce example
// Uses OpenZeppelin's EIP712, ECDSA, and Nonces for complete replay protection
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/Nonces.sol";
contract SecureVault is EIP712, Nonces {
using ECDSA for bytes32;
// STEP 1: Define the typed struct for EIP-712
bytes32 public constant WITHDRAW_TYPEHASH = keccak256(
"Withdraw(address to,uint256 amount,uint256 nonce,uint256 deadline)"
);
address public owner;
constructor() payable EIP712("SecureVault", "1") {
owner = msg.sender;
}
function withdraw(
address _to,
uint256 _amount,
uint256 _deadline,
bytes calldata _signature
) external {
// STEP 2: CHECKS - Verify deadline hasn't passed
require(block.timestamp <= _deadline, "Signature expired");
// STEP 3: EFFECTS - Consume nonce atomically (prevents replay)
// _useNonce returns current nonce and increments it
bytes32 structHash = keccak256(
abi.encode(
WITHDRAW_TYPEHASH,
_to,
_amount,
_useNonce(owner), // Nonce consumed here
_deadline
)
);
// STEP 4: Build EIP-712 digest with domain separator
// Includes: contract name, version, chainId, address(this)
bytes32 digest = _hashTypedDataV4(structHash);
// STEP 5: Recover and verify signer
address signer = digest.recover(_signature);
require(signer == owner, "Invalid signer");
// STEP 6: INTERACTIONS - Transfer funds
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Transfer failed");
}
receive() external payable {}
}
Security features at a glance
| Feature | Protection | How |
|---|---|---|
| EIP-712 Domain Separator | Cross-chain + cross-contract replay | chainId + address(this) in domain |
| Per-user nonce | Same-chain replay | _useNonce(owner) increments atomically |
| Deadline parameter | Stale signature exploitation | block.timestamp <= _deadline check |
| OpenZeppelin ECDSA | Signature malleability | Enforces low-s value requirement |
| Typed data hashing | Blind signing / phishing | Wallets display human-readable signing prompts |
The same combination - domain separator, nonce, deadline - covers the two incidents in this article: the Optimism OP theft and the ETH/ETC exchange drains both came down to a signature that was never bound to a chain.
Advanced replay attack patterns
Beyond the basic patterns, sophisticated replay attacks target complex DeFi architectures.
ERC-2612 permit replay
The permit() function in ERC-2612 lets users approve token spending via signatures instead of on-chain transactions. While the standard includes nonces, implementations sometimes fail:
-
Stale permits without deadlines - A signed permit with
deadline = type(uint256).maxnever expires. If the user's nonce hasn't been consumed, an attacker holding the signature can use it months later at the worst possible price. -
Cross-contract permit replay - If the EIP-712 domain separator is missing or incorrectly implemented, a permit for Token A can be replayed on Token B.
Meta-transaction forwarder replay
Gasless transaction systems (EIP-2771) forward signed messages on behalf of users. The critical vulnerability: if the forwarder hashes the request without a nonce, any relayer - or observer - can replay the same request unlimited times.
In December 2023, an even more dangerous pattern was discovered: contracts implementing both ERC-2771 and Multicall were vulnerable to address spoofing. An attacker could craft malicious calldata within a forwarded request, using Multicall's delegatecall to manipulate _msgSender() resolution. This affected OpenZeppelin, ThirdWeb, and multiple token contracts.
Hard fork chain-split replay
When a blockchain forks without replay protection, every transaction on one chain is valid on the other. This is how 40,000+ ETC was drained from exchanges after the 2016 DAO fork, and how 200 ETHW was stolen via the Omni Bridge just days after the 2022 Ethereum Merge.
EIP-155 was created specifically to solve this by embedding chainId into transaction signatures, but legacy contracts and deployment transactions may still lack this protection.
Raw ecrecover
No nonce, no chainId, no contract address in the hash. Accepts malleable signatures. Every signature is replayable everywhere, forever.
Manual Nonce + chainId
Prevents same-chain replay and cross-chain replay, but missing EIP-712 structured typing means blind signing risk and potential hash collisions from abi.encodePacked.
EIP-712 + Nonces + ECDSA
OpenZeppelin's full stack: domain separator, atomic nonce consumption, deadline, malleability protection, and human-readable wallet prompts. Defense-in-depth.
Common misconceptions
"Adding a nonce prevents all replay attacks."
Tap to revealA nonce alone prevents same-chain replay but NOT cross-chain or cross-contract replay. You also need block.chainid and address(this) in the signed data. EIP-712 domain separators bundle all three protections.
"EIP-155 protects smart contract signatures from replay."
Tap to revealEIP-155 only protects transaction-level signatures (the raw ETH transaction). It does NOT protect application-level signatures verified via ecrecover inside smart contracts. You need EIP-712 domain separators for that.
"Tracking used signature bytes in a mapping prevents replay."
Tap to revealDue to ECDSA signature malleability, an attacker can produce a second valid signature from any existing one by flipping the s value. Track by message hash instead, or better yet, use nonces which inherently prevent replay regardless of malleability.
"OpenZeppelin's ECDSA library alone prevents replay attacks."
Tap to revealOpenZeppelin's ECDSA library prevents signature malleability (by enforcing low-s values) and rejects invalid signatures. But it does NOT add nonces, chain binding, or contract binding. You need the full EIP712 + Nonces stack for complete replay protection.
Related vulnerabilities
Replay attacks can overlap with other smart contract vulnerability classes.
Call and delegatecall attacks also depend on how contracts handle external addresses and message data. A replayed authorization can be more damaging if it enables an arbitrary or privileged external call.
Access control attacks can increase replay impact when a replayable signature authorizes privileged operations or ownership changes.
Flash loan attacks may provide temporary capital for a transaction that combines replayed authorization with price or accounting manipulation.
Test your replay attack knowledge
Test your replay attack IQ
5 questions on nonces, domain separators, and signature scope
Frequently asked questions about replay attacks
A replay attack in blockchain occurs when a valid signature or transaction is captured and resubmitted to execute the same action multiple times without authorization. In smart contracts, this typically happens when off-chain signatures lack a nonce (unique counter) to prevent reuse. The attacker doesn't forge anything - they simply replay something that was legitimately signed.
A common prevention design combines (1) EIP-712 domain separators that bind signatures to a specific chain and contract address, (2) per-user nonces that make each signature single-use, and (3) signature deadlines that expire stale signatures. OpenZeppelin provides reusable EIP712, Nonces, and ECDSA components, which must still be integrated with the protocol's authorization and upgrade model.
A replay attack reuses valid data (signatures or transactions) to repeat an authorized action without permission. A reentrancy attack re-enters a function during execution before state updates complete. Replay exploits data validity; reentrancy exploits execution flow. Both can drain funds, but they require completely different prevention techniques.
EIP-712 is the Ethereum standard for typed structured data hashing and signing. It creates a "domain separator" - a unique hash containing the contract's name, version, chainId, and verifyingContract address. This means a signature created for one contract on one chain cannot be reused on any other contract or chain. It also enables wallets to display human-readable signing prompts instead of opaque hex hashes.
Yes. If a signature does not include the chainId, it may remain valid on another EVM-compatible chain where the same verification context exists. The 2022 Optimism OP token theft involved cross-chain transaction replay, and the 2016 ETH/ETC split caused exchange losses through the same class of missing chain binding.
EIP-155 was created after the 2016 Ethereum/Ethereum Classic fork to prevent transaction-level replay attacks across forked chains. It embeds the chainId into raw transaction signatures, making them chain-specific. However, EIP-155 only protects transactions - it does NOT protect application-level signatures verified via ecrecover inside smart contracts. For that, you need EIP-712.
Quick reference: replay attack prevention checklist
Before deploying any contract that verifies signatures, ensure:
-
Nonce tracking - Each signer has an incrementing nonce included in the signed data
-
Chain ID binding -
block.chainidis part of the signature hash (via EIP-712 domain separator) -
Contract address binding -
address(this)is part of the signature hash (via EIP-712 domain separator) -
Signature deadline -
block.timestamp <= deadlinecheck before processing -
OpenZeppelin ECDSA - Using
ECDSA.recover()instead of rawecrecover(prevents malleability) -
EIP-712 typed data - Structured hashing with domain separator (not raw
keccak256+abi.encodePacked) -
Zero-address check - Recovered signer is validated against
address(0) -
Domain separator recalculation - Not caching
chainIdat deployment (handles chain forks) -
Nonce consumption before external calls - Following CEI pattern for nonce updates
Practice signature review
Signature review becomes clearer when you implement both the vulnerable path and its nonce-protected replacement in a controlled environment.
The Smart Contract Hacking course includes:
-
320+ videos covering replay attacks, reentrancy, flash loans, oracle manipulation, and related topics
-
40+ hands-on exercises exploiting and securing real contracts
-
Instruction from JohnnyTime, Trust, Pashov, and other security researchers
-
A 2,000+ member Discord for technical discussion and peer support
-
SSCH Certification for participants who complete the requirements
Review student outcomes and success stories to decide whether the training fits your goals.
Sources and editorial notes
Reviewed by JohnnyTime. Last updated .
Real Replay Attacks hacks to study
A stable selection of high-signal incidents linked to this attack class, ordered by reported loss and recency.
Master Replay Attacks in a safe lab
Practice the exploit path, debug the vulnerable code, and learn the prevention workflow auditors use in real reviews.