What reentrancy is and why it matters

Reentrancy occurs when a contract makes an external call and the callee re-enters the original contract before the first execution finishes. The attacker uses that second entry to observe or manipulate state that has not yet been updated.

The classic failure mode is:

  1. A contract checks a condition.
  2. It sends Ether or calls another contract.
  3. The recipient’s fallback or callback function calls back into the original contract.
  4. The original contract still has stale state, so the attacker repeats the action.

This is especially dangerous in functions that:

  • transfer Ether,
  • update balances,
  • mint or burn tokens,
  • distribute rewards,
  • process withdrawals,
  • interact with untrusted contracts.

A reentrancy bug is not limited to call. Any external interaction can be a reentry point, including ERC777 hooks, token callbacks, and some DeFi integrations.


A vulnerable withdrawal pattern

Consider a simple vault that lets users withdraw their balance.

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

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");

        // External interaction before state update
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

        balances[msg.sender] -= amount;
    }
}

At first glance, this seems reasonable: verify the balance, send funds, then subtract the amount. The problem is the order. During call, control passes to msg.sender. If msg.sender is a contract, its fallback or receive function can call withdraw() again before balances[msg.sender] is reduced.

Because the balance is still intact, the attacker can withdraw multiple times.


The Checks-Effects-Interactions pattern

The most effective first-line defense is to structure functions so that:

  • Checks validate preconditions.
  • Effects update internal state.
  • Interactions perform external calls last.

This pattern reduces the window in which reentrancy can exploit stale state.

Secure withdrawal example

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

contract SafeVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");

        // Effects first
        balances[msg.sender] -= amount;

        // Interaction last
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

Now, if the recipient re-enters withdraw(), the balance has already been reduced. The second call will fail unless the user still has enough remaining balance.

Why this pattern is not enough by itself

Checks-Effects-Interactions is powerful, but it is not a universal guarantee. You still need additional protections when:

  • a function touches multiple shared state variables,
  • a contract calls into multiple external systems,
  • callbacks can trigger other sensitive functions,
  • invariants span several functions,
  • the same state can be reached through different code paths.

In other words, update state early, but do not assume that alone eliminates all reentrancy risk.


Using a reentrancy guard

A reentrancy guard adds a mutex-like lock around sensitive functions. If a guarded function is already executing, any nested call to another guarded function fails.

This is useful when:

  • multiple functions share the same critical state,
  • you cannot fully reorder interactions,
  • external callbacks are unavoidable,
  • you want a defense-in-depth layer.

Reentrancy guard with a status flag

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

contract GuardedVault {
    mapping(address => uint256) public balances;

    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;
    uint256 private _status = NOT_ENTERED;

    modifier nonReentrant() {
        require(_status != ENTERED, "reentrant call");
        _status = ENTERED;
        _;
        _status = NOT_ENTERED;
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "insufficient balance");
        balances[msg.sender] -= amount;

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

This pattern blocks direct recursive entry into withdraw(). It is especially valuable when a contract has several external entry points that could otherwise be chained together.

Important design note

A reentrancy guard protects only the functions that use it. If one public function is guarded and another related function is not, the unguarded function may still be reachable during a callback. For that reason, apply the guard consistently to all functions that mutate shared sensitive state.


Choosing the right defense

The best defense depends on the contract’s behavior. The following table summarizes common options.

TechniqueStrengthsLimitationsBest use case
Checks-Effects-InteractionsSimple, low overhead, easy to auditNot sufficient for complex multi-function flowsMost withdrawal and payout functions
Reentrancy guardStrong protection against nested entryMust be applied consistentlyShared state across multiple external entry points
Pull paymentsRemoves direct push-based transfersRequires users to claim funds laterRewards, refunds, revenue sharing
Minimal external callsReduces attack surfaceNot always possibleCore accounting logic
Separate accounting and interaction layersImproves clarity and auditabilityMore architecture workLarger protocols and vaults

A practical rule: use Checks-Effects-Interactions by default, then add a reentrancy guard when the contract has more than one sensitive path or any unavoidable callback risk.


Pull payments instead of push payments

One of the safest patterns is to avoid sending Ether during the same transaction that updates accounting. Instead, record what a user can withdraw and let them claim it later.

This is often called a pull payment model.

Example: claim-based rewards

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

contract RewardPool {
    mapping(address => uint256) public rewards;

    function accrueReward(address user) external payable {
        rewards[user] += msg.value;
    }

    function claim() external {
        uint256 amount = rewards[msg.sender];
        require(amount > 0, "nothing to claim");

        rewards[msg.sender] = 0;

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "claim failed");
    }
}

This pattern is safer because the contract does not decide when to push funds to an arbitrary recipient during a sensitive workflow. The user initiates the transfer themselves, and the state is zeroed before the external call.

Pull payments are especially useful for:

  • refunds,
  • staking rewards,
  • affiliate payouts,
  • protocol fee distributions,
  • auction proceeds.

Reentrancy across multiple functions

Reentrancy is not always about a single function calling itself. A callback can enter a different function that shares the same state.

For example:

  • withdraw() reduces balances,
  • transferTo() moves internal credits,
  • emergencyExit() drains funds under certain conditions.

If these functions all depend on the same invariant, they should be treated as one security boundary.

Common mistake

A developer guards withdraw() but leaves emergencyExit() unguarded because it is “admin only.” If the admin role is contract-based, or if another bug exposes the function indirectly, the unguarded path can still be abused during a callback.

Best practice

Group related functions into a single threat model and ask:

  • Which functions can be called during a callback?
  • Which state variables define the invariant?
  • Can one function invalidate assumptions used by another?
  • Are there any external calls before all dependent state is finalized?

If the answer to any of these is uncertain, treat the functions as reentrancy-sensitive.


External calls beyond Ether transfers

Many developers focus only on call{value: ...}. In practice, reentrancy can happen through other external interactions too.

Examples of callback-capable interactions

  • ERC777 token transfers with hooks
  • ERC721 safe transfers invoking onERC721Received
  • ERC1155 safe transfers invoking receiver callbacks
  • Protocol integrations that call back into user contracts
  • Arbitrary external contract methods that may re-enter

This means a contract can be vulnerable even if it never sends Ether directly. Any external call to an untrusted contract should be treated as a potential reentry point.

Practical guidance

  • Assume every external call can execute attacker-controlled code.
  • Finalize internal state before the call.
  • Avoid relying on post-call assumptions unless they are revalidated.
  • Prefer interfaces and patterns that do not require callbacks when possible.

Testing for reentrancy

Security is not complete until the contract is tested under adversarial conditions. Reentrancy tests should simulate a malicious recipient that calls back into the target during execution.

What to test

  • repeated withdrawals in one transaction,
  • nested calls through different entry points,
  • callback-based token transfers,
  • failed external calls and state rollback,
  • partial state updates across multiple variables.

Test strategy

  1. Write a malicious attacker contract.
  2. Trigger the vulnerable function.
  3. Re-enter during the callback.
  4. Assert that balances, totals, and invariants remain correct.

A good test should prove that the exploit either fails or leaves the contract state unchanged.

Example attacker shape

contract Attacker {
    VulnerableVault public vault;
    bool public attacking;

    constructor(address vaultAddress) {
        vault = VulnerableVault(vaultAddress);
    }

    receive() external payable {
        if (attacking) {
            attacking = false;
            vault.withdraw(1 ether);
        }
    }

    function attack() external payable {
        attacking = true;
        vault.deposit{value: msg.value}();
        vault.withdraw(msg.value);
    }
}

This kind of test is valuable because it exercises the exact control-flow pattern that real attackers use.


Best practices for production contracts

Use the following checklist when designing or reviewing Solidity code:

  • Update accounting before external calls.
  • Prefer pull payments over push payments.
  • Apply nonReentrant to all mutually dependent sensitive functions.
  • Minimize the number of external calls in core logic.
  • Treat token transfers and safe receiver hooks as external interactions.
  • Keep invariants simple and explicit.
  • Test with malicious callback contracts, not just happy-path unit tests.
  • Review inherited contracts and composed modules for shared-state interactions.
  • Avoid mixing business logic with payout logic when possible.

A useful mental model is to separate your contract into two phases:

  1. State transition phase: compute and commit all internal changes.
  2. Interaction phase: communicate with the outside world.

The more strictly you preserve that separation, the smaller your reentrancy surface becomes.


When to prefer architectural changes over guards

A guard is a control, not a design substitute. In some systems, the better solution is to redesign the flow entirely.

Consider replacing direct transfers with:

  • claimable balances,
  • escrow contracts,
  • batch settlement,
  • isolated accounting modules,
  • asynchronous finalization.

These designs reduce the number of places where external code can interrupt your logic. They are often easier to audit than a complex function protected by several modifiers and exception paths.

If a contract repeatedly needs to call out during sensitive operations, that is usually a sign that the architecture should be simplified.


Conclusion

Reentrancy is a control-flow vulnerability, so the safest defenses focus on execution order and call boundaries. Start with Checks-Effects-Interactions, add a reentrancy guard where shared state or multiple entry points make it necessary, and prefer pull-based settlement whenever possible.

The most secure contracts are not those that merely “block reentrancy,” but those that make reentrancy irrelevant by design.

Learn more with useful resources