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:

DefensePurposeNotes
NoncePrevents reuse of the same signatureMust be unique per signer or per action
DeadlineLimits how long a signature is validReduces risk from leaked signatures
Domain separatorBinds signature to contract and chainUse EIP-712 when possible
Action-specific structPrevents cross-function reuseInclude all critical parameters
State trackingRecords consumed authorizationsEssential 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.
  • nonce ensures the same signer cannot reuse the same authorization.
  • deadline limits the lifetime of the signature.
  • usedDigests adds 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:

  1. verify signature
  2. check nonce and deadline
  3. mark authorization as used
  4. 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

StrategyReplay resistanceComplexityTypical use
Plain signature over parametersLowLowNot recommended for production
Signature + nonceHighMediumMost authorization flows
Signature + deadlineMediumLowTime-limited approvals
EIP-712 + nonce + deadlineVery highMediumRecommended default
EIP-712 + nonce registry + action hashVery highHigherComplex 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:

  1. signs a typed message off-chain
  2. submits it successfully
  3. submits the same signature again
  4. 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.

Learn more with useful resources