Off-chain signatures are everywhere in modern DeFi: gasless token approvals via EIP-2612, meta-transactions that let relayers pay gas on behalf of users, governance votes that aggregate off-chain intent, and permit-based liquidity operations. Every one of those flows hands a cryptographic token to a counterparty and trusts them to use it only once, only on the correct chain, and only as intended.
That trust breaks in six distinct ways. Each one has cost real projects real money. What makes signature bugs particularly dangerous is that they are often invisible in a cursory audit — the vulnerable line looks correct in isolation, and the flaw only appears when you trace the full lifecycle of a signature from creation to on-chain consumption.
Why Signatures Matter in DeFi
Signatures let users authorize actions without submitting a transaction themselves. The three most common use cases:
- Gasless transactions / meta-transactions: A user signs a payload; a relayer submits it on-chain and pays the gas. The contract verifies the signature and executes as if the user called it directly.
- EIP-2612 permit(): Instead of calling
approve(), a token holder signs an off-chain EIP-712 message that anyone can submit to grant a spending allowance. - Governance votes: Protocols like Compound and Uniswap allow off-chain vote signatures that get batch-submitted in a single transaction.
The security surface is large because signatures are created off-chain (no gas cost, no on-chain trace) and can be collected, replayed, or forged if the verification logic is sloppy.
Vulnerability 1: Missing Nonce — Replay Attack
If a contract accepts the same signature more than once, an attacker who intercepts a valid signature can replay it indefinitely.
Vulnerable code:
// VULNERABLE: no nonce — same signature works forever
contract VulnerableMetaTx {
function execute(
address user,
bytes calldata data,
bytes calldata signature
) external {
bytes32 hash = keccak256(abi.encodePacked(user, data));
address signer = recoverSigner(hash, signature);
require(signer == user, "bad sig");
(bool ok,) = address(this).call(data);
require(ok);
}
}
An attacker records one valid execute() call and replays it any number of times — draining funds, casting votes repeatedly, or triggering state changes the user authorized only once.
Secure pattern: nonce mapping
contract SecureMetaTx {
mapping(address => uint256) public nonces;
function execute(
address user,
bytes calldata data,
uint256 nonce,
bytes calldata signature
) external {
require(nonce == nonces[user], "invalid nonce");
bytes32 hash = keccak256(abi.encodePacked(user, data, nonce));
address signer = recoverSigner(hash, signature);
require(signer == user, "bad sig");
nonces[user]++; // invalidate this signature
(bool ok,) = address(this).call(data);
require(ok);
}
}
Each user's nonce increments after every successful execution. The same signature will never pass nonce == nonces[user] twice.
Vulnerability 2: Missing chainId — Cross-Chain Replay
A signature with no chain binding is valid on every network that runs an identical or forked contract. When a new L2, testnet, or hard fork deploys the same contract at the same address, an attacker can replay mainnet signatures there — or vice versa.
Vulnerable code:
// VULNERABLE: domain separator has no chainId
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,address verifyingContract)"),
keccak256("MyProtocol"),
keccak256("1"),
address(this)
));
Secure EIP-712 domain separator:
bytes32 public DOMAIN_SEPARATOR;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256("MyProtocol"),
keccak256("1"),
block.chainid, // binds to this specific chain
address(this)
));
}
Including block.chainid means a signature produced on Ethereum mainnet (chainId 1) will produce a different digest on Arbitrum (chainId 42161) and fail verification there.
Note: if your contract is upgradeable and the chain forks after deployment, consider recomputing the domain separator dynamically using block.chainid rather than caching it at construction time. OpenZeppelin's EIP712 base contract handles this correctly by storing the chain ID at construction and falling back to dynamic computation if the chain ID changes — inheriting from it is the simplest correct approach.
Vulnerability 3: ecrecover() Returns address(0) on Invalid Input
ecrecover() is a Solidity precompile. When the input signature is malformed or the recovery fails, it does not revert — it returns address(0).
A contract that skips the zero-address check can be tricked by passing a garbage signature. If the contract also has an uninitialized owner variable (or any address that happens to be address(0)), the check passes.
Vulnerable code:
// VULNERABLE: no zero-address check on recovered signer
function verify(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public view returns (bool) {
address signer = ecrecover(hash, v, r, s);
return signer == owner; // passes if owner == address(0)
}
Secure pattern:
function verify(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public view returns (bool) {
address signer = ecrecover(hash, v, r, s);
require(signer != address(0), "ecrecover failed");
return signer == owner;
}
Always assert signer != address(0) immediately after calling ecrecover().
Vulnerability 4: Signature Malleability
ECDSA arithmetic allows two distinct (v, r, s) tuples that produce the same recovered address for the same message. Specifically, for any valid (v, r, s), the tuple (v ^ 1, r, -s mod n) is also a valid signature over the same message.
This matters in two scenarios:
1. A contract uses the raw signature bytes as a unique identifier (e.g., to track used signatures in a mapping). An attacker produces the alternative form and it passes as "unused."
2. A system depends on signatures being unique per submission.
Vulnerable code:
mapping(bytes => bool) public usedSignatures;
function claimOnce(bytes32 hash, bytes calldata sig) external {
require(!usedSignatures[sig], "already used");
address signer = recoverSigner(hash, sig);
require(signer == authorized, "bad sig");
usedSignatures[sig] = true;
// ... pay out
}
The attacker submits the malleable variant of sig. It is not in usedSignatures, passes ecrecover, and the payout fires a second time.
Secure pattern: OpenZeppelin ECDSA.recover()
OpenZeppelin's ECDSA library enforces that s is in the lower half of the curve order (s <= secp256k1n / 2), which eliminates the malleable alternative. It also validates v and rejects the zero-address result.
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
mapping(bytes32 => bool) public usedDigests;
function claimOnce(bytes32 structHash, uint8 v, bytes32 r, bytes32 s) external {
bytes32 digest = _hashTypedDataV4(structHash);
require(!usedDigests[digest], "already used");
address signer = ECDSA.recover(digest, v, r, s); // reverts on invalid sig
require(signer == authorized, "bad sig");
usedDigests[digest] = true;
// ... pay out
}
Track used signatures by their canonical digest, not by the raw (v, r, s) bytes. A digest is unique per message; the raw tuple is not.
Vulnerability 5: Wrong Signer Check — Uninitialized Owner
This vulnerability is subtle: the signer check is syntactically correct but semantically broken because the variable being compared against is address(0).
// VULNERABLE: owner never set — defaults to address(0)
contract VulnerableVault {
address public owner; // never initialized in this path
function withdraw(uint256 amount, bytes calldata sig) external {
bytes32 hash = keccak256(abi.encodePacked(amount));
address signer = recoverSigner(hash, sig);
require(signer == owner, "not owner"); // owner == address(0)
payable(msg.sender).transfer(amount);
}
}
An attacker passes an invalid signature. ecrecover() returns address(0). The check address(0) == address(0) passes. The vault is drained.
This can happen in:
- Contracts with uninitialized storage variables
- Proxied contracts where initialize() was never called
- Contracts that zero out the owner variable intentionally (e.g., renounceOwnership) but still use it in signature checks
Secure pattern:
constructor(address _owner) {
require(_owner != address(0), "zero owner");
owner = _owner;
}
function withdraw(uint256 amount, bytes calldata sig) external {
bytes32 hash = keccak256(abi.encodePacked(amount));
address signer = ECDSA.recover(hash, sig); // reverts on address(0)
require(signer == owner, "not owner");
payable(msg.sender).transfer(amount);
}
Using ECDSA.recover() instead of raw ecrecover() eliminates the zero-address return path entirely.
Vulnerability 6: Signing Over Wrong Data
Two related mistakes fall into this category.
Mistake A: hashing individual fields instead of a typed struct hash
// VULNERABLE: ad-hoc encoding — no type hash, no domain separator
bytes32 hash = keccak256(abi.encodePacked(spender, amount, deadline));
Without a type hash, an attacker can reinterpret the encoded bytes as a different message type. EIP-712 requires a typeHash that encodes the struct definition, making type confusion impossible.
Correct EIP-712 struct hash:
bytes32 constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
function _structHash(
address owner,
address spender,
uint256 value,
uint256 nonce,
uint256 deadline
) internal pure returns (bytes32) {
return keccak256(abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
nonce,
deadline
));
}
function _digest(bytes32 structHash) internal view returns (bytes32) {
return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
}
Mistake B: including mutable state in the signed message
// VULNERABLE: balance can change between signing and execution
bytes32 hash = keccak256(abi.encodePacked(
user,
token.balanceOf(user), // mutable — changes before submission
nonce
));
If the user's balance changes between when the signature is created and when it is submitted — through another transfer, a yield accrual, or an attacker triggering a balance change — the hash no longer matches. Worse, a protocol relying on the balance for authorization can be manipulated into accepting a signature that was valid under different conditions.
Rule: sign over explicit, fixed parameters — amounts, addresses, deadlines — not over derived or live state values.
Complete Secure EIP-712 Implementation
Pulling the patterns together:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SecureSignatureVerifier {
using ECDSA for bytes32;
bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public constant ACTION_TYPEHASH = keccak256(
"Action(address user,uint256 amount,uint256 nonce,uint256 deadline)"
);
mapping(address => uint256) public nonces;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256("SecureProtocol"),
keccak256("1"),
block.chainid,
address(this)
));
}
function executeAction(
address user,
uint256 amount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
require(block.timestamp <= deadline, "signature expired");
uint256 nonce = nonces[user];
bytes32 structHash = keccak256(abi.encode(
ACTION_TYPEHASH,
user,
amount,
nonce,
deadline
));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
// ECDSA.recover() reverts on address(0) and enforces s lower bound
address signer = digest.recover(v, r, s);
require(signer == user, "invalid signature");
nonces[user]++; // invalidate this nonce
// ... execute the action
}
}
This implementation covers: typed struct hash, domain separator with chainId, nonce-based replay protection, deadline enforcement, and malleability-safe recovery.
What ContractScan Detects
| Vulnerability | Detection Method |
|---|---|
| Missing nonce in signature verification | Dataflow analysis: signature path with no nonce increment |
Missing chainId in domain separator |
AST check: EIP712Domain type string missing chainId field |
Unchecked ecrecover() return value |
Control flow: ecrecover() result used without zero-address check |
Raw ecrecover() instead of ECDSA library |
Import and call-site analysis |
| Signature bytes used as unique key | Storage write: mapping(bytes => bool) keyed on raw signature |
| Mutable state in signed payload | Dataflow: balanceOf, totalSupply, or state variable reads inside keccak256 of signed data |
| Uninitialized signer variable in comparison | Storage analysis: comparison target never assigned before use |
Check Your Contracts
Signature vulnerabilities are among the hardest to catch in manual review because the bug is often not in one file — it is in the relationship between the off-chain signing code and the on-chain verification logic.
ContractScan traces the full signature verification path across your contracts, flags missing nonces, unguarded ecrecover() calls, absent chain bindings, and malleability exposure, and explains exactly which lines need to change.
Check your signature handling at https://contract-scanner.raccoonworld.xyz
Related Reading
- EIP-2612 Permit() and Signature Replay Attacks: The Hidden Risk in Modern DeFi Tokens
- Solidity Access Control Patterns: onlyOwner, Roles, and Multisig
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.