“Immutable” is the most abused word in this industry. It gets stamped on landing pages, printed in audit summaries, and repeated in Discord by people who have never read the storage layout of the thing they are defending. The uncomfortable truth is that most “immutable” contracts sit behind a proxy, and behind that proxy is a key, and behind that key is a person, or a multisig, or a compromised laptop. Code you cannot change is a security property. Code that one address can replace at will is a promise, and promises are not a threat model.

The Standard

The dominant pattern for upgradeability in the EVM ecosystem is the proxy, and OpenZeppelin’s proxy libraries are the reference implementation most teams actually deploy. The mechanics are worth stating plainly because the abstraction is where people stop thinking.

A proxy is a thin contract that holds the state and the balance. It contains almost no logic of its own. Every call it receives, it forwards via delegatecall to a separate implementation contract (also called the logic contract). Because delegatecall executes the implementation’s bytecode in the proxy’s storage context, the implementation defines behavior while the proxy owns the data. Upgrading means pointing the proxy at a new implementation address. The state stays; the logic is swapped underneath it.

Two patterns dominate. The Transparent Proxy Pattern (EIP-1967 for storage slots, plus a ProxyAdmin contract) routes upgrade calls through a dedicated admin address and everything else through to the implementation, avoiding function selector clashes. UUPS (Universal Upgradeable Proxy Standard, EIP-1822) moves the upgrade logic into the implementation itself via an upgradeTo function guarded by an access control check, which makes the proxy cheaper but means a botched implementation can permanently remove the ability to upgrade. EIP-1967 standardizes the specific storage slots (implementation, admin, beacon) so tooling can find them deterministically instead of colliding with application state.

The critical detail that every one of these standards shares: upgradeability requires a privileged role. The ProxyAdmin owner. The account authorized to call upgradeTo. The beacon owner in the Beacon Proxy pattern, who can upgrade an entire fleet of proxies in a single transaction. OpenZeppelin’s documentation is not shy about this. The library gives you AccessControl and Ownable and expects you to lock the role down. What it cannot do is stop you from setting that role to a hot wallet and forgetting about it.

So the standard, stated honestly, is this: you get to change the code, and the only thing standing between users and arbitrary new code is whoever controls the admin key. Everything else is implementation detail.

Where It Breaks Down

The failure modes are boring, repetitive, and entirely predictable, which is exactly why they keep happening.

The admin is an EOA. A single externally owned account, one private key, controls the upgrade. This is the original sin. Now the “immutability” of the protocol is exactly as strong as one seed phrase, and that seed phrase is subject to phishing, malware, a compromised CI pipeline that had deploy keys, or a founder who signs a malicious transaction because the wallet UI rendered upgradeTo as an opaque blob of calldata.

The multisig is theater. Teams graduate to a Safe and consider the problem solved. But a 2-of-3 where all three signers use the same hardware in the same office, or where two of the three keys live on machines the same DevOps engineer administers, is a 2-of-3 in name and a 1-of-1 in practice. Signer independence is the entire point of a multisig, and it is the first thing sacrificed for convenience. Worse is the multisig with a low threshold and inactive signers, where the “quorum” is really just the two people who reliably show up.

No timelock, or a timelock that lies. OpenZeppelin ships a TimelockController for a reason: it forces a delay between when an upgrade is queued and when it can execute, giving users a window to read the proposed implementation and exit if they do not like it. Plenty of protocols skip it entirely, so an upgrade lands atomically with no warning. Others deploy a timelock and then hold the admin role over the timelock itself, or set the delay to something cosmetic like an hour, or retain a separate “emergency” path that bypasses the delay. A timelock you can cancel or route around is documentation, not a control.

Storage layout mistakes turn upgrades into corruption. Because the implementation runs in the proxy’s storage, appending a variable in the wrong slot, reordering existing variables, or changing a type reinterprets existing state as something else. The __gap variable convention exists precisely to reserve slots in upgradeable base contracts, and it is routinely omitted or miscounted. Storage collisions do not always announce themselves. A balance mapping can quietly start reading from a different slot, and the first sign of trouble is funds that no longer add up.

Uninitialized implementations. Upgradeable contracts replace constructors with initialize functions, because a constructor runs in the implementation’s context, not the proxy’s, and therefore never touches proxy state. If the implementation contract is deployed and left uninitialized, an attacker can call initialize on the implementation directly, take ownership of it, and (in UUPS setups) call upgradeTo or selfdestruct through it. The _disableInitializers call in the constructor exists to prevent exactly this, and it is exactly the line people delete when they are copying code and hitting compiler errors.

Opaque governance that is upgrade-in-disguise. A DAO that can pass a proposal to change the admin, or to upgrade directly, is an admin key wearing a costume. If a small number of delegates or a single large holder can push a proposal through, the decentralization is nominal. The threat model did not disappear; it moved to the token distribution, where nobody is auditing it.

Doing It Right

If you are a builder, the target is not “never upgrade.” Upgradeability is a legitimate tool for fixing bugs. The target is to make the upgrade path slow, visible, and expensive to abuse.

  • Put the admin behind a timelock, and put the timelock in front of everything. Use OpenZeppelin’s TimelockController with a delay measured in days, not hours. The multisig proposes; the timelock enforces the wait; the community can watch. Do not keep a bypass. If you have an emergency pause requirement, separate pause (a narrow, reversible circuit breaker) from upgrade (the thing that can replace all logic) and never let the pause role touch the upgrade slot.

  • Make the multisig real. Independent signers, independent hardware, geographic and organizational separation, a threshold that survives losing your least reliable signer. Rotate keys when people leave. Document who holds what, and treat that document as sensitive.

  • Publish the storage layout and diff it on every upgrade. Use OpenZeppelin Upgrades plugins (Hardhat or Foundry) which validate storage compatibility and flag unsafe operations before deployment. Keep __gap slots in your base contracts and account for them. Verify the new implementation on a block explorer before, not after, execution.

  • Disable initializers on implementations. Call _disableInitializers() in the constructor of every UUPS or transparent implementation. Confirm it. This is a one-line defense against a catastrophic class of takeover.

  • Consider a credible path to immutability. If your endgame is a fixed protocol, hand the admin to a burn address or a timelock with no owner, and say so on-chain in a way that can be verified. “We will decentralize later” is not a control; a renounced admin key is.

If you are a holder, do the boring work. Read the EIP-1967 slots. Find out who holds the upgrade role. Check whether there is a timelock and what its delay is. Watch the admin address for queued proposals. A protocol that will not tell you who can replace its code has already told you something.

The Bottom Line

Immutable code is a real and valuable property, and almost nothing you interact with actually has it. What most protocols have is upgradeable code with a governance story, and the security of that story collapses to the security of one key, one quorum, one timelock delay. The word “immutable” costs nothing to print and buys a great deal of trust it has not earned. Read the slots. Trust the delay, not the tweet. The code you are relying on is only as fixed as the person who can change it lets it be.

It’s already priced. The admin key isn’t.

More: Issues · Field Notes · RTFM