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.chainidat 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:
| Scenario | Recommended deadline |
|---|---|
| User-signed permit | Short (minutes to hours) |
| Protocol governance vote | Medium (hours to days) |
| Off-chain order books | Embed in order struct, match expiry |
| Batch operations | Per-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
verifyingContractis 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:
| # | Mistake | Impact | Fix |
|---|---|---|---|
| 1 | Missing deadline check | Signature valid forever | require(block.timestamp <= deadline) |
| 2 | Missing owner != address(0) check | Attacker can forge approval from zero address | Explicit zero-address guard or use OZ ECDSA |
| 3 | Nonce checked but not incremented | Sequential replay still possible | _nonces[owner]++ inside the call |
| 4 | Nonce incremented after external call | Reentrancy can re-enter before nonce bumps | Increment before _approve |
| 5 | Raw ecrecover without malleability guard | Attacker morphs s to bypass hash deduplication | Use OZ ECDSA.recover |
| 6 | verifyingContract omitted from domain | Signature works on any contract with same name | Always include address(this) |
| 7 | Domain separator computed once at deploy and never updated | Hard-fork chain ID change breaks signatures or creates confusion | Cache + lazy recompute on block.chainid mismatch |
| 8 | permit() does not revert on invalid sig — it silently skips | Downstream logic sees stale approval | Require 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
-
chainIdis included — prevents cross-chain replay -
verifyingContractisaddress(this)— prevents cross-contract replay -
nameandversionare included — human-readable binding - Domain separator is recomputed if
chainIdchanges (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
deadlineorexpirytimestamp -
block.timestamp <= deadlineis enforced before signature verification - Deadline is set by the signer, not the relayer or submitter
ECDSA implementation
- OpenZeppelin
ECDSA.recoveris used, not rawecrecover - 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
-
_hashTypedDataV4or equivalent is used, not rawkeccak256(structHash) - The
TYPEHASHstring exactly matches the struct field names and types, in order - Nested struct types are included in the type string
EIP-2612 permit()
-
deadlinecheck is present -
owner != address(0)check is present -
nonces[owner]is incremented before the approval is applied - The
PERMIT_TYPEHASHmatches the standard exactly