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:

  1. build a message hash
  2. apply the Ethereum signed message prefix or EIP-712 domain separation
  3. recover the signer
  4. 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:

  • v is 27 or 28
  • s is 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:

  • ECDSA for safe signature recovery
  • EIP712 for domain separation and typed data
  • SignatureChecker for 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:

MethodBest forSecurity notes
ecrecoverSimple ECDSA verificationMust handle malleability and domain separation manually
OpenZeppelin ECDSAStandard EOAsSafer recovery and cleaner code
OpenZeppelin SignatureCheckerEOAs and smart contract walletsSupports ERC-1271 contract signatures
EIP-712Structured approvals and ordersStrongly 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.

Learn more with useful resources