Initializer Pitfalls

A non-upgradeable contract sets up its state in a constructor, which runs exactly once, at deployment, in the contract's own storage. A proxy-based upgradeable contract cannot use a constructor for this purpose: code in the implementation's constructor runs against the implementation's storage, not the proxy's, so the proxy never sees the result.

The workaround is the initializer — a regular function, called by the proxy via delegatecall immediately after deployment, that performs the setup that a constructor would have done. This pattern is necessary, ubiquitous, and the source of an oversized share of upgrade-related findings.

The Basic Pattern

OpenZeppelin's Initializable is the canonical implementation:

contract Vault is Initializable, OwnableUpgradeable {
    uint256 public cap;

    function initialize(address owner_, uint256 cap_) external initializer {
        __Ownable_init(owner_);
        cap = cap_;
    }
}

initializer is a modifier that allows the function to run exactly once. It uses a storage flag to track whether initialization has happened, and reverts on any subsequent call.

This looks straightforward. In practice, the following pitfalls recur across audits.

Pitfall 1: Unprotected Implementation Initializer

When the implementation contract is deployed, its own storage is empty — including the initializer's _initialized flag. If nothing prevents it, anyone can call initialize() directly on the implementation contract address and take ownership of it.

In a Transparent or Beacon proxy this is a curiosity. In a UUPS proxy it is catastrophic: the attacker, now owner of the implementation, can call the implementation's own upgradeToAndCall(address newImpl, bytes calldata data) directly. Because UUPS implementations contain the upgrade logic, this lets the attacker swap the implementation's own code — and historically (pre-Cancun, with selfdestruct semantics that still bite some patterns), they could brick the implementation, taking every proxy that pointed to it down with it. This is what happened to the Wormhole UUPS implementation in 2022 (the patch landed before exploitation, but the vector was real).

The fix, in every UUPS implementation:

constructor() {
    _disableInitializers();
}

_disableInitializers() sets the _initialized flag to its maximum value, permanently blocking any initializer call on this code instance. The implementation can never be initialized when called directly; only proxies (which have their own, separate _initialized slot) can.

Audit checklist for every UUPS implementation:

  • The constructor calls _disableInitializers().
  • No code path calls initialize on the implementation after deployment.
  • The implementation contract is verified on Etherscan (so anyone can audit that the constructor was called).

This is not optional. It is the single most important guard in a UUPS deployment.

Pitfall 2: Initializer Front-Running

A common deployment pattern, especially with Hardhat scripts circa 2021, looked like:

// 1. Deploy proxy with implementation address, no calldata
// 2. Send a second transaction calling proxy.initialize(...)

Between transactions 1 and 2, the proxy exists but is uninitialized. An observer who sees the deployment can submit proxy.initialize(...) with their own parameters and front-run the team. They become owner of the protocol before the legitimate deployer's transaction lands.

The fix:

  • Deploy and initialize in a single transaction, by passing the encoded initialize(...) calldata to the proxy constructor (ERC1967Proxy(impl, data)) or, for UUPS, by using upgradeToAndCall immediately.
  • Use @openzeppelin/hardhat-upgrades or @openzeppelin/foundry-upgrades which do this correctly by default.
  • Never deploy a proxy in one transaction and initialize it in another.

This vector is still seen, particularly in custom proxy implementations and CREATE2-based factory patterns where the developer assumed the address was unpredictable.

Pitfall 3: Missing Initializer Modifier

If a function intended to be an initializer is missing the initializer modifier:

function initialize(address owner_) external {  // <-- forgot the modifier
    _transferOwnership(owner_);
}

…then it can be called repeatedly, and anyone can re-initialize the contract at any time. This is rare in modern code (linters catch it) but recurs in custom systems that don't inherit from Initializable.

A related variant: the modifier is present on initialize but not on a reinitialize or migrate function intended to run once during an upgrade.

Pitfall 4: Initializers in Base Contracts

When an upgradeable contract inherits from multiple upgradeable bases, each base usually exposes its own __Base_init function intended to be called once. The derived contract's initialize must call each of them, exactly once, in the right order.

function initialize(...) external initializer {
    __Ownable_init(...);     // base init
    __ReentrancyGuard_init(); // base init
    __ERC20_init("Name", "SYM"); // base init
    // ...derived setup
}

Failure modes:

  • Forgetting to call a base init. State that the base relies on (e.g. owner) is never set; effects range from silent broken behavior to permanent denial of service.
  • Calling a base init twice across initializer and reinitializer. The base's onlyInitializing modifier guards against this in OpenZeppelin's code, but custom bases may not.
  • Calling base inits in the wrong order, with one base's init depending on another's state.

The audit task is to verify every inherited __Base_init is called, exactly once, in the right order, in the chain of initializers.

Pitfall 5: reinitializer Misuse

OpenZeppelin's Initializable supports versioned re-initialization via the reinitializer(uint64 version) modifier, intended for use during upgrades when a new implementation needs to set up new state.

function initializeV2(uint256 newParam) external reinitializer(2) {
    newField = newParam;
}

Pitfalls:

  • Forgetting to call reinitializeV2 during the upgrade. New state is left at default values; the upgrade is silently broken.
  • Reusing a version number. Each reinitializer(N) can run only once, but if two unrelated migration functions both use version 2, only the first one can run.
  • Calling base inits inside reinitializer that were already called in V1's initializer. Most bases protect against this with onlyInitializing, but custom bases may double-initialize.
  • Public reinitializer with no access control. The reinitializer modifier prevents multiple calls but does not restrict who can make the first one — exactly like initializer. If the migration is publicly callable, the first attacker to spot the new deployment owns the new state.

For each reinitializer in scope, an audit should verify: a unique version number, appropriate access control (often onlyOwner or onlyProxyAdmin), correct ordering of base initializers, and that the deployment script actually calls it as part of the upgrade.

Pitfall 6: Constructor Logic Leaking into the Implementation

Code in the implementation's constructor runs only on the implementation, never on the proxy. This is the entire reason initializers exist — but the lesson is sometimes only partially internalized. Patterns to watch for:

  • Setting immutable variables in the constructor. These are baked into the implementation's bytecode and are visible to the proxy (because the proxy executes the implementation's bytecode). This is fine — even useful — but it means upgrading to a new implementation with different immutable values changes the protocol's behavior in a way that isn't obvious from storage diffs. Worth flagging in upgrade review.
  • Constructor logic that does anything other than _disableInitializers(). Any side effect that mutates storage is lost. Any external call from the constructor runs from the implementation's address, not the proxy's, with predictable confusion.
  • Importing a non-upgradeable base (Ownable instead of OwnableUpgradeable). The non-upgradeable version uses a constructor; the upgradeable version uses an initializer. Mixing them in a proxy-based contract sets the owner on the implementation, not the proxy, leaving the proxy ownerless.

Pitfall 7: Re-entrant Initializer

If initialize makes an external call before completing its setup, the called contract can re-enter and observe partially-initialized state — or, if the call goes to attacker-controlled code, re-enter initialize itself before the initializer modifier's flag has settled.

function initialize(address token_, address feeRecipient_) external initializer {
    token = IERC20(token_);
    IFeeManager(feeRecipient_).register();  // <-- external call mid-init
    // attacker-controlled feeRecipient_ can re-enter here
}

Modern OpenZeppelin Initializable sets the flag before the function body executes (so re-entrant initialize calls are blocked), but the state visible to the callee is still half-baked. The fix is the same as for any re-entrancy: complete all storage writes before making external calls (CEI), and validate that the external callee is trusted or harmless.

A Minimal Audit Checklist for Initializers

For every upgradeable contract in scope, an auditor should verify:

  • The constructor of any UUPS implementation calls _disableInitializers().
  • No code path can call initialize on the implementation directly.
  • Proxy deployment and initialize are atomic (same transaction, via constructor calldata).
  • The initializer modifier is present on every initialization entry point.
  • Every inherited __Base_init is called exactly once, in the right order.
  • Every reinitializer(N) has a unique N, has appropriate access control, and is actually invoked during the corresponding upgrade.
  • No external calls are made mid-initialization before the contract's invariants hold.
  • All upgradeable bases in the inheritance chain are the upgradeable variants (OwnableUpgradeable, not Ownable).
  • No immutable variable change is silently introduced in an upgrade without surfacing it in the upgrade notes.

A protocol that passes all of these has eliminated the most common class of initializer-related findings. A protocol that fails any of them has a recurring source of high-severity bugs that will eventually be found by someone less friendly than the auditor.