What delegatecall actually does

Unlike a normal external call, delegatecall runs the callee’s code in the context of the calling contract. In practice, this means:

  • address(this) remains the caller contract
  • storage writes affect the caller’s storage
  • msg.sender and msg.value are preserved from the original transaction
  • the callee cannot directly access its own storage when executed via delegatecall

This is why delegatecall powers upgradeable proxies: the proxy stores state, while the implementation contract provides logic.

Why developers use it

Common use cases include:

  • upgradeable proxy contracts
  • modular contract systems
  • shared libraries with stateful behavior
  • on-chain plugin execution

The flexibility is valuable, but the security model is strict: if the delegated code is malicious, buggy, or incompatible with the caller’s storage layout, the caller contract can be corrupted.


The core security risk: code runs with your storage

The most important rule is simple: never delegatecall untrusted code.

A delegated contract can:

  • overwrite critical storage slots
  • change ownership or access control flags
  • corrupt balances and accounting variables
  • brick upgradeability by modifying implementation pointers
  • self-destruct the calling contract in older EVM contexts or through dangerous patterns

Because the delegated code executes in your contract’s context, it is effectively as powerful as your own code.

Example of a dangerous pattern

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract UnsafeExecutor {
    address public plugin;

    function setPlugin(address _plugin) external {
        plugin = _plugin;
    }

    function execute(bytes calldata data) external returns (bytes memory) {
        (bool ok, bytes memory result) = plugin.delegatecall(data);
        require(ok, "delegatecall failed");
        return result;
    }
}

This contract is dangerous because anyone who can influence plugin can execute arbitrary code in the storage context of UnsafeExecutor. If plugin is set to a malicious contract, it can overwrite plugin, seize ownership variables, or alter any state variable by writing to the correct slot.


Storage layout mismatches are a hidden hazard

Even when the delegated contract is trusted, mismatched storage layouts can break your system. This is especially common in upgradeable contracts and modular architectures.

If the implementation contract assumes a different order or type of state variables than the proxy, storage writes will land in the wrong slots.

Example of a layout collision

contract ProxyStorage {
    address public owner;      // slot 0
    uint256 public balance;    // slot 1
}

contract LogicV1 {
    address public admin;      // slot 0
    uint256 public total;      // slot 1
}

If LogicV1 is used via delegatecall, admin maps to owner and total maps to balance. That may be acceptable if intentional, but it becomes dangerous when later versions reorder variables or insert new ones at the top.

Best practice

Use one of these approaches:

ApproachWhen to useSecurity notes
Fixed storage layoutSimple proxy systemsRequires disciplined versioning
Unstructured storageAdvanced proxy patternsReduces collision risk if implemented correctly
Storage gapsUpgradeable contractsHelps preserve future layout space
Explicit storage librariesComplex systemsImproves clarity and reduces accidental overlap

For upgradeable systems, storage gaps and strict append-only variable ordering are the most practical safeguards.


Restrict who can trigger delegated execution

If your contract exposes a generic execution function, access control must be extremely tight. A public delegatecall entry point is almost always a vulnerability unless it is intentionally designed as an execution sandbox with strong constraints.

Safer pattern: allowlisted implementations

Instead of accepting arbitrary addresses, restrict execution to a known set of audited modules.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract ModuleHost {
    mapping(address => bool) public approvedModule;

    address public owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function approveModule(address module, bool approved) external onlyOwner {
        approvedModule[module] = approved;
    }

    function runModule(address module, bytes calldata data)
        external
        returns (bytes memory)
    {
        require(approvedModule[module], "module not approved");

        (bool ok, bytes memory result) = module.delegatecall(data);
        require(ok, "module failed");
        return result;
    }
}

This does not make delegatecall inherently safe, but it reduces exposure by limiting execution to vetted code.

Additional restrictions to consider

  • require onlyOwner or role-based access for module updates
  • use a timelock for upgrades
  • emit events when modules are approved or revoked
  • require code hash verification for high-assurance deployments

Never delegatecall to user-supplied addresses

A common anti-pattern is allowing users to pass in a target address for delegatecall. Even if the function seems harmless, the target can write to storage, bypass checks, or alter future behavior.

Bad example

function arbitraryExecute(address target, bytes calldata data) external {
    (bool ok, ) = target.delegatecall(data);
    require(ok, "failed");
}

This is effectively remote code execution in the contract’s storage context.

Safer alternatives

If you need extensibility, prefer:

  • normal call for isolated external execution
  • predefined modules with allowlists
  • pure/view libraries when no state mutation is needed
  • off-chain computation followed by verified on-chain updates

Use delegatecall only when state sharing is intentional and tightly controlled.


Handle return data and failures correctly

Low-level calls do not automatically revert with useful errors. You must check the success flag and decide how to handle returned bytes.

Recommended pattern

(bool success, bytes memory returndata) = implementation.delegatecall(data);
if (!success) {
    assembly {
        revert(add(returndata, 32), mload(returndata))
    }
}

This preserves the original revert reason, which is valuable for debugging and safer operational behavior.

Why this matters

If you simply do require(success, "failed"), you lose the underlying error context. In production systems, that makes incident analysis harder and can hide important failure modes.


Use delegatecall only with carefully designed storage access

When delegated logic needs to read or write state, make the storage layout explicit. Avoid relying on inherited variables scattered across multiple contracts unless the inheritance tree is stable and well understood.

Prefer storage libraries for shared state

A common safe pattern is to centralize state access through a library that returns a fixed storage pointer.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

library AppStorage {
    bytes32 internal constant STORAGE_SLOT = keccak256("example.app.storage");

    struct Layout {
        address owner;
        uint256 counter;
    }

    function layout() internal pure returns (Layout storage s) {
        bytes32 slot = STORAGE_SLOT;
        assembly {
            s.slot := slot
        }
    }
}

contract Logic {
    using AppStorage for AppStorage.Layout;

    function increment() external {
        AppStorage.Layout storage s = AppStorage.layout();
        s.counter += 1;
    }
}

This pattern reduces accidental slot collisions because the storage location is fixed and not dependent on variable ordering.


Understand how msg.sender affects authorization

A subtle property of delegatecall is that msg.sender remains the original caller of the outermost transaction. This is useful for proxies because the implementation can enforce permissions as if it were called directly.

However, it also means delegated code may behave differently than expected if it assumes the immediate caller is the module host.

Example risk

Suppose a module checks:

require(msg.sender == owner, "not owner");

If the module is executed via delegatecall, msg.sender is the external user, not the host contract. That check may fail unexpectedly, or worse, pass in a context you did not intend.

Best practice

Design delegated modules with a clear authorization model:

  • use the proxy’s storage for ownership checks
  • avoid assumptions about the immediate caller
  • document whether the module is meant for direct calls, delegated calls, or both

Compare delegatecall with other call types

Call typeStorage contextTypical useMain security concern
callCallee storageInteracting with external contractsReentrancy, return handling
staticcallCallee storage, read-onlyQueries and verificationUnexpected state assumptions
delegatecallCaller storageProxies, shared logic, pluginsStorage corruption, code injection

If you do not need shared storage, call is usually safer. If you only need read access, staticcall is the right choice. Reserve delegatecall for cases where shared state is a deliberate design requirement.


Practical checklist for secure delegatecall usage

Before shipping a contract that uses delegatecall, verify the following:

  1. The target address is never user-controlled.
  2. The implementation is allowlisted or otherwise authenticated.
  3. Storage layout is stable and documented.
  4. Upgrade procedures are restricted and auditable.
  5. Revert reasons are bubbled up correctly.
  6. Module code is reviewed with the same rigor as the host contract.
  7. Emergency pause or rollback mechanisms exist for upgradeable systems.
  8. Tests cover storage integrity across upgrades and module changes.

Testing scenarios you should include

  • delegated writes to expected storage slots
  • invalid module address rejection
  • revert bubbling from delegated code
  • upgrade from one implementation version to another
  • state preservation after multiple delegated calls
  • authorization behavior under delegated execution

A good test suite should include both happy-path functionality and deliberate malicious-module simulations.


When delegatecall is the right tool

Use delegatecall when you need:

  • a proxy that preserves state across logic upgrades
  • a modular architecture where modules operate on shared storage
  • a carefully controlled execution environment with known code

Avoid it when you only need:

  • external interaction with another contract
  • isolated business logic
  • read-only data access
  • user-defined extensibility without strong trust guarantees

The guiding principle is trust: if the code is not trusted to write directly into your contract’s storage, it should not be executed via delegatecall.


Conclusion

delegatecall is neither inherently unsafe nor broadly recommended. It is a specialized mechanism for advanced contract architectures, especially proxies and modular systems. Its safety depends on strict control over target code, stable storage layout, and disciplined upgrade procedures.

If you treat delegated code as fully trusted, document storage assumptions, and limit who can change implementations, you can use delegatecall effectively without exposing your contract to arbitrary storage corruption.

Learn more with useful resources