Manav.id
Crypto · 4 min read

Domain separation only works if the executing contract checks it

Domain separation only works if the executing contract checks it

The specification puts a chain identifier in the domain separator precisely so a signature for one chain cannot be replayed on another. That protection is only real if the contract verifies the value at execution rather than trusting one it cached.

How does caching an EIP-712 domain separator break replay protection?

EIP-712 puts a chain identifier in the domain separator so a signature cannot be replayed on another chain. Many contracts compute that separator once in the constructor and store it. If the chain forks, or identical bytecode is deployed at the same address elsewhere, the cached value stops separating anything.

Key takeaways
  • The domain separator binds a signature to a contract, a version and a chain. Caching it at deployment breaks the chain binding.
  • Chain forks and deployments of identical bytecode across chains are where this becomes exploitable.
  • The fix is small — recompute or compare the chain identifier at execution — and the bug is easy to miss in review because the code looks correct.

What the domain separator does

Deployseparator cachedgas savingFork or redeploychain id changescache staleSignature presentedother chainseparator matchesAcceptedreplayedno security reason
Two of the four domain elements stop distinguishing anything once the address matches and the chain id is cached.

Typed structured data signing hashes a domain object alongside the message. The domain typically contains the contract name, a version, a chain identifier and the verifying contract's address.

Including all four means a signature is valid only for that message, to that contract, on that chain. Change any element and the hash changes and the signature fails.

The optimisation that breaks it

Computing the separator involves several hashes, which costs gas. A common optimisation computes it once in the constructor and stores it.

// Common pattern — computed once, stored
constructor() {
    DOMAIN_SEPARATOR = keccak256(abi.encode(
        TYPE_HASH, nameHash, versionHash,
        block.chainid,          // captured at deployment
        address(this)
    ));
}

// At execution, the stored value is used.
// If block.chainid has changed since deployment,
// the separator no longer reflects the current chain.

On a chain that never forks and where the contract exists at only one address, this is harmless and saves gas on every call. Neither condition is guaranteed.

Two situations where it matters

SituationConsequence
The chain forks and the identifier changesSignatures issued before the fork remain valid on both chains
Identical bytecode is deployed at the same address on another chainA signature for one deployment validates on the other

The second is more common than it sounds. Deterministic deployment patterns are used deliberately to place contracts at matching addresses across chains, which is convenient and removes the address element's distinguishing power.

With the address matching and the chain identifier cached, two of the four domain elements no longer separate anything.

The fix

// Cache, but verify the cache is still valid
function _domainSeparator() internal view returns (bytes32) {
    if (block.chainid == _cachedChainId
        && address(this) == _cachedThis) {
        return _cachedSeparator;
    }
    return _buildSeparator();     // recompute
}

This keeps the gas saving in the common case and recomputes when the assumption no longer holds. Widely used library implementations do exactly this, which is an argument for using them rather than hand-rolling the pattern.

Why review misses it

Three reasons, and they compound.

  1. The code looks right. The chain identifier is present in the computation. A reviewer scanning for its presence finds it.
  2. It cannot be tested locally. Reproducing it needs a chain identifier change, which does not happen in normal test environments.
  3. The pattern is widespread. Seeing it in many codebases suggests it is correct rather than that it is a common error.

What to check

The last one catches an unrelated but more damaging error that appears in the same code region, which is a reason to review the whole signature verification path rather than only the separator.

The broader lesson

A separator is only separating if it is computed from current reality at the moment of verification. Caching any security-relevant value turns it into an assumption, and assumptions need to be checked rather than trusted.

The same pattern appears well outside smart contracts: cached permission decisions, cached tenant identifiers, cached environment flags. Each is a gas optimisation of a different kind, with the same failure mode.

Why review misses it

Three reasons, and they compound. The chain identifier is visibly present in the computation, so a reviewer scanning for it finds it. The condition cannot be reproduced in a normal test environment, because chain identifiers do not change there. And the pattern appears in many codebases, which reads as confirmation rather than as a common error.

What the four domain elements are supposed to separate
ElementSeparatesFails when
name / versionOne contract's messages from another'sRarely
chainIdOne chain from anotherCached at deployment
verifyingContractOne deployment from anotherDeterministic deployment puts it at the same address
TogetherAll fourTwo of them stop varying

Deterministic deployment patterns are used deliberately to place contracts at matching addresses across chains, which is convenient and removes the address element's distinguishing power. Combined with a cached chain identifier, half the separator stops working.

Objections and honest limits

“Caching is a legitimate gas optimisation.” It is, and the fix keeps it. Cache, but validate the cache against the current chain id and address, and recompute when they differ. Widely used library implementations already do exactly this.

“Our chain will never fork.” Possibly. The second failure mode does not need a fork — only identical bytecode at the same address on another chain, which is a deployment convenience rather than an event.

// Cache, but verify the cache is still valid
function _domainSeparator() internal view returns (bytes32) {
    if (block.chainid == _cachedChainId
        && address(this) == _cachedThis) {
        return _cachedSeparator;
    }
    return _buildSeparator();     // recompute
}

Reviewing a signature verification path

  1. Is the separator cached, and is the cache validated? Against both the current chain id and the current address.
  2. Is the signature bound to a nonce, and is the nonce consumed? A replay inside the same chain is the more common bug.
  3. Is there an expiry? A signature valid indefinitely defeats any timelock above it.
  4. Does the same bytecode exist at the same address elsewhere? Deterministic deployment removes the address element.
  5. Is the recovered signer checked against an expected value? Or does the code accept any structurally valid signature? This one is more damaging and lives in the same function.

Terms used here

Domain separator
A hash over the contract name, version, chain identifier and verifying contract address, included in an EIP-712 signature so it is valid only in that context.
Deterministic deployment
Deploying a contract to a predictable address, often identical across chains, so integrations can hard-code it.
Replay
Presenting a valid signature in a context it was not intended for — another chain, another contract, or a second time.

Frequently asked questions

What does the domain separator protect against? Replay across contracts, versions and chains. A signature is valid only for the message, contract and chain the separator encodes.

Why is caching it a problem? The cached value reflects the chain identifier at deployment. If the chain forks, or identical bytecode exists elsewhere at the same address, the separator stops distinguishing.

Why do reviewers miss it? The chain identifier is visibly present in the computation, the condition cannot be reproduced in normal test environments, and the pattern appears in many codebases.

What is the fix? Validate the cache against the current chain identifier and address, recomputing when they differ. Widely used library implementations already do this.

Where this fits in Manav

The same pattern appears far outside smart contracts: cached permission decisions, cached tenant identifiers, cached environment flags. Manav's rule is that a security-relevant value is recomputed from current reality at verification time rather than trusted from a cache.

Read the architecture →

Sources and further reading