Cross-Chain Replay: Why Your Signed Message Needs chainId
Your signature is a bearer asset on every EVM chain that accepts it. Forget chainId in your EIP-712 domain and an attacker replays the same signed message on Ethereum, Polygon, BSC, and every testnet fork — draining bridges and permit-based vaults. Multiple post-Merge L2 forks turned this oversight…
The Bug in One Sentence
If your contract verifies a signature without binding it to `block.chainid`, that signature is valid on every EVM chain — mainnet, every L2, every testnet, and every contentious fork that hasn't happened yet.
Signatures are bearer instruments. They don't know what chain you're on. The only thing stopping a `permit()` signed for Ethereum from executing on Polygon is the data the signer hashed and the data the verifier rechecks. Leave `chainId` out of either side and you've shipped a free replay primitive.
Why This Keeps Happening
EIP-155 fixed transaction replay in 2016 by mixing `chainId` into the transaction signing hash. Great. But application-layer signatures — `permit`, meta-transactions, bridge attestations, off-chain orders — are a separate problem. EIP-712 solved it by defining a `domain separator` that includes `chainId`, `verifyingContract`, and a few other fields.
The trap: developers compute the domain separator **once in the constructor** and cache it. That's fine until the chain forks, or until you deploy the same bytecode to multiple chains at the same address (which is exactly what deterministic deployers encourage).
The Vulnerable Pattern
```solidity
contract VulnerableVault {
bytes32 public immutable DOMAIN_SEPARATOR;
mapping(address => uint256) public nonces;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
keccak256(bytes("VulnerableVault")),
block.chainid, // captured ONCE at deploy
address(this)
));
}
function withdrawWithSig(
address owner,
uint256 amount,
uint8 v, bytes32 r, bytes32 s
) external {
bytes32 structHash = keccak256(abi.encode(
keccak256("Withdraw(address owner,uint256 amount,uint256 nonce)"),
owner, amount, nonces[owner]++
));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
require(ecrecover(digest, v, r, s) == owner, "bad sig");
_payout(owner, amount);
}
}
```
Two failure modes hiding here:
1. **Chain fork**: ETH PoW forked off Ethereum in September 2022. Any contract holding state on both sides accepted signatures bound to `chainId = 1` — even though the fork's true chainId was 10001. The cached `DOMAIN_SEPARATOR` lied.
2. **Multichain deploys**: Deploy the same vault at the same address on Ethereum and Arbitrum. A user signs a withdraw for Arbitrum. An attacker submits it on Ethereum first (or vice versa). The domain separators are different — but if a sloppy variant drops `chainId` entirely (and plenty do), the signature replays cleanly.
I see the second pattern constantly in [free AI audits](https://www.cryptohawking.com/audit) — devs copy a `permit` implementation from a single-chain protocol, deploy it cross-chain, and never revisit the assumption.
The Fix
Recompute the domain separator when `block.chainid` changes. OpenZeppelin's modern `EIP712` does this; older versions did not.
```solidity
contract SafeVault {
bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
uint256 private immutable _CACHED_CHAIN_ID;
bytes32 private immutable _HASHED_NAME;
constructor() {
_HASHED_NAME = keccak256(bytes("SafeVault"));
_CACHED_CHAIN_ID = block.chainid;
_CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator();
}
function _domainSeparator() internal view returns (bytes32) {
if (block.chainid == _CACHED_CHAIN_ID) return _CACHED_DOMAIN_SEPARATOR;
return _buildDomainSeparator();
}
function _buildDomainSeparator() private view returns (bytes32) {
return keccak256(abi.encode(
keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
_HASHED_NAME,
block.chainid,
address(this)
));
}
}
```
For bridge messages — where the signer attests to an action on a *destination* chain — bind both the source and destination chainIds into the payload:
```solidity
bytes32 structHash = keccak256(abi.encode(
WITHDRAW_TYPEHASH,
srcChainId,
dstChainId, // attacker can't replay on a different destination
recipient,
amount,
nonce
));
require(dstChainId == block.chainid, "wrong chain");
```
And always include a nonce scoped per `(signer, chainId)` — not just per signer.
Real Incidents
**Wintermute / Optimism (June 2022)**: $20M in OP tokens sent to a counterparty address that didn't exist on Optimism. Not strictly a signature replay, but the same root cause — assuming an address or message valid on one chain is valid on another.
- **Qubit Bridge (January 2022, $80M)**: Logic flaw where a deposit event on one chain produced withdrawable balance on another without proper chain binding in the verification path.
- **Post-Merge ETHW fork**: Multiple lending protocols and DEXs saw signed `permit` calls and meta-transactions from mainnet replayed on the PoW fork. Smaller dollar values individually but a textbook demonstration that cached domain separators are time bombs.
- **L2 testnet → mainnet leaks**: Several teams have had signatures collected from testnet UIs successfully replayed on mainnet because the verifier never checked chainId at all.
The pattern is always the same: the signer thought they were authorizing one action on one chain. The verifier had no way to disagree.
Audit Checklist
Before you ship anything that calls `ecrecover`:
Domain separator includes `block.chainid` **and** is recomputed when chainid changes.
- Every signed payload either uses EIP-712 properly or explicitly includes `block.chainid` in the hash preimage.
- Bridge messages bind both source and destination chainIds.
- Nonces are tracked per signer and never reused across chains (or are namespaced by chainId).
- Signatures have an `expiry` / `deadline`. Long-lived signatures + future fork = future loss.
- You've tested what happens if your contract is deployed to a new chain via CREATE2 at the same address.
If you're shipping a bridge, a permit-based vault, or any meta-tx system, a [manual audit](https://www.cryptohawking.com/audit/manual) is cheap insurance — three business days, $5,000 in ETH/SOL/USDT, and a human reading every signature path. Replay bugs are the kind of thing static analyzers miss because the code compiles, executes, and passes every unit test you wrote. The bug only shows up when somebody runs your contract on a chain you didn't think about.
TL;DR
Signatures are bearer. `chainId` is the only thing that pins them to a chain. Put it in the domain separator, recompute it when chainid changes, and bind destination chainId into bridge payloads. Anything less and you're trusting attackers not to notice the next fork.
FAQ
Doesn't EIP-155 already prevent replay attacks?
EIP-155 prevents replay of *raw transactions* across chains by mixing chainId into the transaction signing hash. It does nothing for application-layer signatures — `permit`, off-chain orders, bridge attestations, meta-transactions. Those are signed with `ecrecover` over a payload your contract defines. If you don't include chainId in that payload (typically via the EIP-712 domain separator), EIP-155 won't save you. The two protections operate at different layers and you need both.
Why recompute the domain separator instead of caching it?
Caching is a gas optimization that breaks when `block.chainid` changes — which happens on chain forks. The ETHW fork after the Merge is the canonical example: contracts cached `chainId = 1` at deploy, the fork ran with `chainId = 10001`, but verifiers still accepted mainnet-signed messages on the fork. The pattern is: cache it for the common case, but if `block.chainid != cachedChainId`, recompute on the fly. OpenZeppelin's modern EIP712 implementation does exactly this.
Is including chainId in the signed payload enough, or do I need EIP-712?
Functionally, including chainId anywhere in the hash preimage prevents cross-chain replay. EIP-712 is preferred because wallets render the structured data to users — signers see what they're authorizing instead of an opaque 32-byte hash. From a security standpoint, raw `keccak256(abi.encode(chainId, ...))` works. From a UX and phishing-resistance standpoint, EIP-712 is dramatically better. Use EIP-712 unless you have a hard reason not to.
What about signatures intended to work across multiple chains?
Rare but legitimate — for example, a governance signature that authorizes the same action on every chain a protocol is deployed to. In that case, explicitly encode an array of allowed chainIds, or use a sentinel value like `chainId = 0` meaning "any chain." Then verify `chainId == 0 || chainId == block.chainid`. The point is to make multi-chain validity an explicit, audited choice — not an accident from forgetting to bind the chain.
How do I test for cross-chain replay vulnerabilities?
Write a Foundry test that deploys your contract twice — once with `vm.chainId(1)` and once with `vm.chainId(137)` — at the same address using CREATE2. Sign a message against the first instance, then attempt to submit it against the second. It should revert. Repeat for the chain-fork case: deploy, sign, then call `vm.chainId(10001)` and replay. Any path that doesn't revert is a finding. This catches both the cached-domain-separator bug and the missing-chainId bug in one suite.