
Preventing Signature Replay Attacks in Solidity with Domain Separation
What a replay attack looks like
A replay attack occurs when an attacker reuses a valid signature to repeat an action that was intended to happen only once. The signature itself may be correct; the problem is that the contract accepts it again.
Common examples include:
- A user signs a withdrawal authorization, and the same signature is used twice.
- A signature intended for one contract is accepted by another contract with the same verification logic.
- A message signed on one chain is valid on a fork or another chain because the signed data does not bind to chain context.
- A permit or meta-transaction can be executed repeatedly because the contract does not track nonce usage.
Replay bugs are especially dangerous because they often pass basic testing. The signature verification may be cryptographically sound, yet the application logic remains vulnerable.
Why domain separation matters
A signature should describe not only what action is authorized, but also where and for whom it is valid. This is the purpose of domain separation.
A well-designed signed message should bind to:
- the contract address
- the chain ID
- the intended action or function
- a unique nonce
- an optional deadline or expiration
Without these fields, a signature can be portable across contexts. That portability is exactly what attackers exploit.
EIP-712 as the standard approach
In Solidity, the most robust pattern for structured signatures is EIP-712 typed data. It hashes a domain separator together with a typed message, producing a signature that is specific to a contract and chain.
Compared with ad hoc keccak256(abi.encodePacked(...)) schemes, EIP-712 is easier to reason about and much harder to misuse.
Core defenses against replay
The main defenses are straightforward:
| Defense | Purpose | Notes |
|---|---|---|
| Nonce | Prevents reuse of the same signature | Must be unique per signer or per action |
| Deadline | Limits how long a signature is valid | Reduces risk from leaked signatures |
| Domain separator | Binds signature to contract and chain | Use EIP-712 when possible |
| Action-specific struct | Prevents cross-function reuse | Include all critical parameters |
| State tracking | Records consumed authorizations | Essential for one-time actions |
These controls work best together. A nonce without a domain separator can still be replayed on another contract. A domain separator without a nonce can still be replayed in the same contract.
A secure EIP-712 pattern
The following example shows a simple authorization flow where a user signs permission for a recipient to withdraw a specific amount once.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract WithdrawalAuthorizer is EIP712 {
using ECDSA for bytes32;
bytes32 private constant WITHDRAW_TYPEHASH =
keccak256("Withdraw(address owner,address to,uint256 amount,uint256 nonce,uint256 deadline)");
mapping(address => uint256) public nonces;
mapping(bytes32 => bool) public usedDigests;
constructor() EIP712("WithdrawalAuthorizer", "1") {}
function withdraw(
address owner,
address to,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "signature expired");
uint256 nonce = nonces[owner];
bytes32 structHash = keccak256(
abi.encode(
WITHDRAW_TYPEHASH,
owner,
to,
amount,
nonce,
deadline
)
);
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = digest.recover(signature);
require(recovered == owner, "invalid signature");
require(!usedDigests[digest], "signature already used");
usedDigests[digest] = true;
nonces[owner] = nonce + 1;
// Effects before interactions
payable(to).transfer(amount);
}
}Why this design is safer
This contract uses several layers of protection:
EIP712("WithdrawalAuthorizer", "1")creates a domain separator tied to the contract name and version._hashTypedDataV4(structHash)binds the signature to the current chain ID and contract address.nonceensures the same signer cannot reuse the same authorization.deadlinelimits the lifetime of the signature.usedDigestsadds an explicit replay barrier, which is useful when the same digest might be processed through multiple code paths.
In many designs, either nonces or usedDigests is sufficient. Using both is conservative, but it can be helpful when multiple entry points or batched operations exist.
Choosing the right nonce model
Nonce design depends on your application.
Sequential nonce
A sequential nonce increments after each successful authorization.
Best for:
- single-action approvals
- simple meta-transactions
- user-centric workflows
Pros:
- easy to implement
- easy to audit
- compact storage
Cons:
- signatures must be used in order
- parallel signed requests are harder to manage
Per-action nonce
A nonce is tracked separately for each action type or target.
Best for:
- systems with multiple independent authorization flows
- contracts that support several signed operations
Pros:
- more flexible
- reduces accidental invalidation of unrelated signatures
Cons:
- more storage
- more complex verification logic
Random unique nonce
The user signs a random unique value, and the contract stores whether it has been used.
Best for:
- one-off authorizations
- voucher systems
- off-chain generated permits
Pros:
- no ordering constraints
- easy to invalidate a specific authorization
Cons:
- requires reliable nonce generation off-chain
- storage grows with each used authorization
Common mistakes that lead to replay bugs
1. Omitting the contract address
If the signed message does not include the verifying contract, the same signature may be valid in another contract that uses the same verification logic.
2. Omitting chain ID
A signature that is valid on one chain may also be valid on another chain or fork if the message is not chain-specific.
3. Using abi.encodePacked unsafely
Packed encoding can create ambiguous encodings for multiple dynamic values. That ambiguity can lead to collisions in message hashes.
Prefer abi.encode for structured data.
4. Reusing the same signature across functions
If one signature can authorize multiple actions, an attacker may call the most favorable function instead of the intended one.
Make the signed type specific to the exact action.
5. Forgetting to consume the nonce before external calls
If the contract transfers tokens or Ether before marking the signature as used, a reentrant or repeated path may exploit the gap.
Always update state first, then interact externally.
6. Accepting signatures without expiration
A signature with no deadline can remain valid indefinitely. If it is leaked, copied, or sold, it may be abused long after the user intended.
Practical guidance for real projects
Use OpenZeppelin primitives
OpenZeppelin’s EIP712 and ECDSA utilities are widely used and well reviewed. They reduce the chance of implementing hashing and recovery incorrectly.
Keep the signed payload minimal but complete
Include every parameter that affects the authorization outcome:
- recipient
- amount
- token address
- nonce
- deadline
- action identifier
If a field changes the meaning of the authorization, it belongs in the signed struct.
Separate authorization from execution
A good pattern is:
- verify signature
- check nonce and deadline
- mark authorization as used
- execute the action
This order prevents duplicate execution even if the final step fails later in the flow.
Emit events for consumed authorizations
Events help off-chain systems track which authorizations were used. They are not a security control by themselves, but they improve observability and incident response.
Consider batch operations carefully
If a single signature authorizes a batch of actions, define exactly what the batch contains. A signature over “execute batch” is not enough unless the batch contents are hashed and included in the signed data.
Comparing replay protection strategies
| Strategy | Replay resistance | Complexity | Typical use |
|---|---|---|---|
| Plain signature over parameters | Low | Low | Not recommended for production |
| Signature + nonce | High | Medium | Most authorization flows |
| Signature + deadline | Medium | Low | Time-limited approvals |
| EIP-712 + nonce + deadline | Very high | Medium | Recommended default |
| EIP-712 + nonce registry + action hash | Very high | Higher | Complex multi-action systems |
For most applications, EIP-712 with a per-user nonce and deadline is the best balance of safety and usability.
Testing replay resistance
Replay protection should be tested explicitly, not assumed.
Test cases to include
- The same signature succeeds once and fails on second use.
- A signature generated for one contract is rejected by another contract.
- A signature generated on one chain is rejected after chain ID changes.
- An expired signature is rejected.
- A signature with altered parameters is rejected.
- A signature cannot be reused through an alternate function or code path.
Example test idea
If you use Foundry or Hardhat, write a test that:
- signs a typed message off-chain
- submits it successfully
- submits the same signature again
- expects a revert with a replay-related error
This test should be part of your regression suite. Replay bugs often reappear when authorization logic is refactored.
When a simple nonce is not enough
A nonce alone prevents exact reuse, but not always misuse.
Suppose your contract accepts signatures for both withdraw and approveSpender. If both use the same nonce space but do not include an action discriminator in the signed struct, a signature intended for one action may be interpreted as another. The nonce would not save you if the message itself is ambiguous.
For that reason, the signed type should always encode the semantic meaning of the action. A nonce prevents repetition; a typed message prevents substitution.
Recommended checklist
Before shipping a signature-based feature, verify the following:
- The signature uses EIP-712 typed data.
- The domain includes the contract and chain context.
- Every authorization has a nonce.
- The nonce is consumed exactly once.
- The signature has a deadline.
- The signed struct includes all security-relevant parameters.
- State is updated before external calls.
- Tests cover duplicate submission and cross-context reuse.
If any of these items is missing, the design may still be vulnerable to replay.
Conclusion
Replay attacks are not a cryptography problem; they are a protocol design problem. A signature can be valid and still unsafe if the contract does not bind it to the right context and enforce one-time use.
The safest default in Solidity is to use EIP-712, include a nonce and deadline, and store consumed authorizations before executing the action. With those measures in place, signature-based features become much harder to abuse and much easier to audit.
