Signature Verification Attacks

Understand signature bugs in Solidity, from vulnerable code to EIP-712 defenses and permit risks.

Signature verification attacks happen when a contract accepts a valid signature in the wrong context. The signature may be real. The private key may be uncompromised. The bug is that the contract verifies too little.

Permits, orders, withdrawals, and delegations turn signed messages into authority. A secure contract must verify both who signed and exactly what they authorized.

What must a signature authorize?

Missing Binding Result
Nonce Same signature can be reused
Deadline Signature may remain valid forever
Chain ID Cross-chain replay risk
Verifying contract Cross-contract replay risk
Function intent Signature for one action authorizes another
Recipient or spender Funds or allowance go to the wrong address

The dangerous part is that ECDSA.recover() can return the expected signer while the authorization is still broken.

How signature verification fails

A user signs a withdrawal. An attacker copies the signature from public calldata and submits it again. If the contract only checks the recovered signer, the same authorization may execute twice.

Signed intentWithdraw 1 ETH to Bob

Without a nonce

Checks the signer. Does not record that this authorization was used.

1 ETHpaid to Bob

First withdrawal accepted

With a nonce

Checks the signer and signed nonce. Advances the nonce after use.

1 ETHpaid to Bob

First withdrawal accepted · nonce now 1

Both withdrawals have executed once. Reusing the signature pays Bob again only when nonce protection is missing.

This example assumes sufficient balance. The recipient is signed, so replay repeats payment to Bob, not an arbitrary attacker address. Redirecting funds is a different bug: the recipient must be missing from, or incorrectly bound to, the signed payload.

Vulnerable Solidity example

// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
contract BadSignatureVault {
    mapping(address => uint256) public balances;

    function withdrawWithSig(
        address owner,
        address to,
        uint256 amount,
        bytes calldata sig
    ) external {
        bytes32 digest = keccak256(abi.encodePacked(owner, to, amount));

        address recovered = ECDSA.recover(digest, sig);
        require(recovered == owner, "bad signature");

        // No nonce, deadline, domain or typed intent.
        balances[owner] -= amount;
        payable(to).transfer(amount);
    }
}

This contract checks who signed, but not whether the signature is fresh, contract-specific, chain-specific, or single-use.

Preventing signature misuse with EIP-712

Three checks do different jobs: the domain binds the message to this contract and chain; the nonce makes the action single-use; the deadline limits its lifetime. EIP-712 structures the message but does not consume nonces for you.

The essential verification inside the withdrawal function is:

require(block.timestamp <= deadline, "expired");
uint256 nonce = nonces[owner]++;
bytes32 structHash = keccak256(abi.encode(
    WITHDRAW_TYPEHASH, owner, to, amount, nonce, deadline
));
bytes32 digest = _hashTypedDataV4(structHash);
require(ECDSA.recover(digest, signature) == owner, "bad signature");

The nonce increment rolls back if verification or payment reverts. The typed struct binds the recipient and amount; the EIP-712 domain binds the chain and verifying contract.

View the full Solidity example
contract SafeSignatureVault is EIP712, ReentrancyGuard {
    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(address => uint256) public balances;

    constructor() EIP712("SafeSignatureVault", "1") {}

    function withdrawWithSig(
        address owner,
        address payable to,
        uint256 amount,
        uint256 deadline,
        bytes calldata signature
    ) external nonReentrant {
        require(block.timestamp <= deadline, "expired");
        require(owner != address(0), "owner zero");
        require(to != address(0), "to zero");

        uint256 nonce = nonces[owner]++;

        bytes32 structHash = keccak256(
            abi.encode(
                WITHDRAW_TYPEHASH, owner, to, amount, nonce, deadline
            )
        );

        bytes32 digest = _hashTypedDataV4(structHash);
        address recovered = ECDSA.recover(digest, signature);

        require(recovered == owner, "bad signature");

        balances[owner] -= amount;
        (bool sent, ) = to.call{value: amount}("");
        require(sent, "payment failed");
    }
}

The full example shows the withdrawal path; imports and deposit accounting are omitted. It uses OpenZeppelin EIP712, ECDSA and ReentrancyGuard. Balance updates happen before the external payment call.

The example is EOA-only because it uses ECDSA.recover. Production flows that support smart accounts should use EIP-1271-aware verification such as OpenZeppelin SignatureChecker.isValidSignatureNow().

Permit signature risks

permit is useful because it lets users approve token allowance with a signature instead of an approval transaction. That also makes it dangerous when the UX or validation is weak.

Review permit flows for:

  • unlimited approvals,

  • missing deadlines,

  • wrong spender,

  • reused nonces,

  • confused token domains,

  • Permit2 or aggregator flows where users do not understand what they signed.

Not every permit drain is a protocol bug. Many are phishing. But protocols still need to design signatures so a leaked or misused signature has the smallest possible blast radius.

Signature verification audit checklist

  • Every signature has a nonce or unique order ID.

  • Nonces are consumed exactly once.

  • Signatures include deadlines.

  • EIP-712 domain includes name, version, chain ID, and verifying contract.

  • Signed struct includes recipient, spender, token, amount, and action intent.

  • Raw ecrecover is avoided or fully hardened.

  • abi.encodePacked is not used for ambiguous typed messages.

  • Contract wallets are handled with EIP-1271 where required.

  • Cancelled orders and invalidated nonces stay invalid after upgrades.

  • Tests cover replay, wrong contract, wrong chain, expired signature, wrong amount, and wrong recipient.

Test cases for signature bugs

Failure Test Case
Missing nonce Use the same signature twice
Missing chain ID Replay on a fork or different chain config
Missing contract address Deploy clone and reuse signature
Missing deadline Execute old signature long after signing
Missing recipient Redirect output to attacker

If a signature authorizes value movement, test it like an entrypoint.

  • Replay attacks: how valid authorizations are reused across executions, contracts, or chains.

  • Phishing attacks: how users are tricked into signing harmful payloads, even when verification works correctly.

  • Access control attacks: how authorization mistakes grant unintended privileges.

FAQ

Does EIP-712 prevent replay attacks automatically?

No. EIP-712 structures the data and domain, but the contract must still include and consume nonces, deadlines, and security-critical fields.

What is domain separation?

Domain separation binds a signature to a specific application context, usually including protocol name, version, chain ID, and verifying contract.

Should contracts use ecrecover directly?

Usually no. Prefer audited libraries such as OpenZeppelin ECDSA, EIP712, and SignatureChecker because raw signature handling is easy to get subtly wrong.

References

Practice smart contract security

Build your auditing skills with guided exploit exercises in the Smart Contract Hacking course.