
Secure Signature Verification in Solidity Smart Contracts
Why signature verification matters
A signature proves that a private key approved a specific message. In Solidity, this is commonly used for:
- gasless approvals and meta-transactions
- off-chain order signing in marketplaces
- authorization for privileged actions
- permit-based token spending
- signed vouchers, coupons, and claims
The security challenge is not just “does the signature recover the expected signer?” It is also:
- What exactly was signed?
- Can the same signature be replayed?
- Is the message bound to this contract and this chain?
- Is the encoding unambiguous?
- Is the signature format safe against malleability?
A secure design answers all of these questions explicitly.
The core verification primitive
Most Solidity signature verification starts with ecrecover, which extracts an address from a message hash and a signature. The usual flow is:
- build a message hash
- apply the Ethereum signed message prefix or EIP-712 domain separation
- recover the signer
- compare the recovered address to the expected signer
Here is a minimal example using the personal-sign style prefix:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleVerifier {
function verify(
address expectedSigner,
bytes32 messageHash,
bytes calldata signature
) external pure returns (bool) {
bytes32 ethSignedMessageHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
);
(bytes32 r, bytes32 s, uint8 v) = splitSignature(signature);
address recovered = ecrecover(ethSignedMessageHash, v, r, s);
return recovered == expectedSigner;
}
function splitSignature(bytes calldata sig)
internal
pure
returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "invalid signature length");
assembly {
r := calldataload(sig.offset)
s := calldataload(add(sig.offset, 32))
v := byte(0, calldataload(add(sig.offset, 64)))
}
}
}This is enough for a demo, but not enough for production. It lacks replay protection, domain separation, and malleability checks.
Prefer EIP-712 for structured data
For real applications, use EIP-712 typed structured data instead of signing raw hashes. EIP-712 makes the signed payload human-readable in wallet UIs and prevents ambiguity in how fields are encoded.
Why EIP-712 is safer
With raw abi.encodePacked(...), different inputs can sometimes produce the same byte sequence. That creates encoding collisions. EIP-712 avoids this by hashing a typed struct with a domain separator.
A robust EIP-712 domain usually includes:
- contract name
- version
- chain ID
- verifying contract address
This binds the signature to one application instance on one chain.
Example: signed authorization with nonce and deadline
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VoucherRedeemer {
bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public constant REDEEM_TYPEHASH =
keccak256("Redeem(address recipient,uint256 amount,uint256 nonce,uint256 deadline)");
mapping(address => uint256) public nonces;
address public signer;
constructor(address _signer) {
signer = _signer;
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("VoucherRedeemer")),
keccak256(bytes("1")),
block.chainid,
address(this)
)
);
}
function redeem(
address recipient,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "signature expired");
uint256 nonce = nonces[recipient];
bytes32 structHash = keccak256(
abi.encode(REDEEM_TYPEHASH, recipient, amount, nonce, deadline)
);
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
);
address recovered = _recover(digest, signature);
require(recovered == signer, "invalid signature");
nonces[recipient] = nonce + 1;
// Execute redemption logic here
}
function _recover(bytes32 digest, bytes calldata signature)
internal
pure
returns (address)
{
require(signature.length == 65, "invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := calldataload(signature.offset)
s := calldataload(add(signature.offset, 32))
v := byte(0, calldataload(add(signature.offset, 64)))
}
require(v == 27 || v == 28, "invalid v");
// Reject malleable signatures by enforcing lower-S values.
require(
uint256(s) <= 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff,
"invalid s"
);
address recovered = ecrecover(digest, v, r, s);
require(recovered != address(0), "invalid signer");
return recovered;
}
}This pattern adds three critical protections:
- domain separation via
DOMAIN_SEPARATOR - replay protection via
nonces - expiry via
deadline
Common security pitfalls
1. Replay attacks
If a signature can be used more than once, an attacker can reuse it to repeat the same action. This is especially dangerous for:
- token claims
- order fills
- coupon redemption
- admin approvals
Use one or more of the following:
- per-user nonces
- per-order unique IDs
- deadlines
- consumed-signature tracking when appropriate
A nonce is usually the best default because it is compact and easy to reason about.
2. Missing domain separation
A signature intended for one contract should not be valid in another contract or on another chain. Without a domain separator, the same signed payload may be replayed across deployments.
Always bind signatures to:
address(this)block.chainid- a version string if the schema may evolve
3. Ambiguous encoding
Avoid building signed payloads with abi.encodePacked unless you are certain the fields cannot collide. For example, concatenating dynamic values can be dangerous.
Prefer:
abi.encode(...)for struct hashing- EIP-712 typed data
- fixed-width fields where possible
4. Signature malleability
ECDSA signatures have a malleability issue: for a given message and signer, there can be more than one valid signature representation unless you enforce canonical form.
Defensive checks should include:
vis 27 or 28sis in the lower half order- recovered address is nonzero
If you use OpenZeppelin’s ECDSA library, these checks are already handled for you.
5. Verifying the wrong signer
A signature is only meaningful if you know whose authority it represents. Common mistakes include:
- comparing against
tx.origin - accepting any recovered address without checking role membership
- using a signer address that can be changed without access control
Always define the trust model explicitly. For example, is the signer:
- a backend service?
- the user themselves?
- a multisig?
- a role-based admin?
Recommended implementation patterns
Use OpenZeppelin helpers
In production, prefer audited libraries instead of hand-rolled recovery logic. OpenZeppelin provides:
ECDSAfor safe signature recoveryEIP712for domain separation and typed dataSignatureCheckerfor ECDSA and contract-based signatures
These libraries reduce the chance of subtle bugs.
Track nonces carefully
A nonce should be:
- unique per signer
- incremented only after successful verification
- included in the signed payload
- stored in a way that is easy to query
If the contract supports multiple action types, consider separate nonce spaces for each type. That prevents one signed authorization from accidentally authorizing another action.
Bind the signature to the exact action
The signed message should include every field that affects contract behavior. If the contract later uses a parameter that was not signed, an attacker may alter it.
For example, if a signed order includes recipient and amount, but not token, then the order may be replayed for a different token if the contract allows it. Sign all security-relevant parameters.
ECDSA vs contract signatures
Not all valid signatures come from externally owned accounts. Some wallets and smart accounts validate signatures through contract code. In those cases, ecrecover is not enough.
The table below summarizes the main options:
| Method | Best for | Security notes |
|---|---|---|
ecrecover | Simple ECDSA verification | Must handle malleability and domain separation manually |
OpenZeppelin ECDSA | Standard EOAs | Safer recovery and cleaner code |
OpenZeppelin SignatureChecker | EOAs and smart contract wallets | Supports ERC-1271 contract signatures |
| EIP-712 | Structured approvals and orders | Strongly recommended for production authorization flows |
If your application may interact with smart wallets, SignatureChecker is often the best choice because it supports ERC-1271, which lets contract accounts define their own signature validation logic.
Practical best practices
1. Prefer typed data over raw hashes
Typed data is easier to audit and less error-prone. It also improves wallet UX because users can see the fields they are signing.
2. Include a deadline
Deadlines limit the lifetime of a signature. This reduces the damage from leaked signatures and stale approvals.
3. Use per-action nonces
Do not reuse the same nonce for unrelated operations unless that is a deliberate design choice. A shared nonce can create accidental denial of service or cross-function replay issues.
4. Reject zero address recoveries
ecrecover returns address(0) on failure. Never treat that as a valid signer.
5. Keep the signed schema stable
If you change the struct layout, version the domain or introduce a new type hash. Otherwise, old signatures may become invalid or, worse, be interpreted incorrectly.
6. Test edge cases
Add tests for:
- expired signatures
- reused nonces
- wrong chain ID
- wrong verifying contract
- malformed signatures
- signatures from unauthorized signers
- contract wallet signatures if supported
A secure checklist for production
Before shipping signature-based authorization, verify that your contract:
- uses EIP-712 or an equally strong domain-separated scheme
- includes all security-relevant fields in the signed payload
- checks a nonce or unique identifier
- enforces a deadline when appropriate
- validates the signer against an explicit trust model
- rejects malleable or malformed signatures
- supports ERC-1271 if smart wallets are in scope
- has tests for replay and cross-domain misuse
When not to use signatures
Signature verification is powerful, but it is not always the right tool. If the action is simple and should only be performed by an on-chain role, a standard access control check may be clearer and safer. Use signatures when you need:
- off-chain authorization
- gas savings for users
- delegated execution
- portable approvals across relayers or frontends
If the contract can enforce the rule directly on-chain with no UX benefit from signing, prefer the simpler design.
Conclusion
Secure signature verification in Solidity is about more than recovering an address. A production-ready design must define the exact message, bind it to the correct domain, prevent replay, and reject non-canonical signatures. EIP-712, nonces, deadlines, and audited helper libraries form the foundation of a robust implementation.
If you treat signatures as structured authorization rather than just cryptographic proof, your contracts will be much harder to exploit and much easier to maintain.
