What reentrancy is and why it matters

A reentrancy attack occurs when a contract makes an external call to an untrusted address, and that callee calls back into the original contract before the first execution completes. If the original contract has not yet updated its state, the attacker may repeat the same action multiple times.

The classic example is a withdrawal function:

  1. The contract checks that the user has enough balance.
  2. It sends Ether to the user.
  3. It updates the user’s balance afterward.

If the recipient is a malicious contract, its fallback or receive function can call the withdrawal function again before the balance is reduced. The result is repeated withdrawals from the same balance.

Reentrancy is not limited to Ether transfers. It can happen with:

  • ERC777 token hooks
  • External protocol callbacks
  • Low-level calls
  • Cross-contract function chains
  • Even seemingly harmless view-like interactions if they trigger state changes elsewhere

The core defense: ReentrancyGuard

OpenZeppelin’s ReentrancyGuard is the standard Solidity utility for preventing a function from being entered again while it is already executing. It works by maintaining a status flag that marks the contract as “entered” during protected execution.

When a function marked nonReentrant is called:

  • the guard checks whether the contract is already in an entered state
  • if yes, it reverts
  • if no, it marks the contract as entered
  • the function executes
  • the guard resets the status afterward

This is a simple and effective defense for functions that must make external calls.

When to use it

Use ReentrancyGuard when a function:

  • transfers Ether or tokens to an external address
  • calls into another contract before finishing internal accounting
  • performs callback-based integration with third-party protocols
  • manages user balances, escrow, vault shares, or withdrawal logic

It is especially useful in contracts that hold funds or manage claimable assets.


A vulnerable withdrawal example

Consider this simplified contract:

// 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");

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

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

The problem is the order of operations. The contract sends Ether before reducing the sender’s balance. If msg.sender is a contract, its fallback function can reenter withdraw() and drain funds repeatedly.


Applying ReentrancyGuard

A safer version uses ReentrancyGuard and updates state before the external call:

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

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

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

    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, ) = payable(msg.sender).call{value: amount}("");
        require(ok, "transfer failed");
    }
}

This version uses two layers of protection:

  1. Checks-effects-interactions: state is updated before the external call.
  2. nonReentrant: even if the external call tries to reenter, the guard blocks it.

The combination is stronger than either technique alone.


Understanding the nonReentrant limitation

A subtle but important detail: functions marked nonReentrant cannot call each other directly. Since the guard prevents reentry into any protected function, this pattern can cause internal design friction.

A common solution is to split logic into:

  • an external nonReentrant entry point
  • a private or internal function containing the shared implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Example is ReentrancyGuard {
    function withdraw(uint256 amount) external nonReentrant {
        _withdraw(msg.sender, amount);
    }

    function emergencyWithdraw(uint256 amount) external nonReentrant {
        _withdraw(msg.sender, amount);
    }

    function _withdraw(address user, uint256 amount) private {
        // shared logic
    }
}

This structure keeps the external API protected while preserving code reuse.


Reentrancy protection strategies compared

StrategyWhat it doesBest forLimitations
Checks-effects-interactionsUpdates state before external callsMost fund-handling functionsEasy to forget in complex flows
ReentrancyGuardBlocks nested entry into protected functionsHigh-risk external-call pathsCannot protect internal logic by itself
Pull paymentsLets users withdraw funds themselvesEscrow and payoutsNot always suitable for synchronous workflows
Minimal external callsReduces attack surfaceGeneral contract designNot always possible

In practice, the best defense is usually a combination of these techniques.


Designing reentry-resistant flows

1. Minimize external calls

Every external call is a potential reentry point. If your contract can complete its work without calling out, do that first. For example, avoid calling token contracts, price feeds, or arbitrary receivers inside the middle of a state transition unless necessary.

2. Update all critical state before interaction

If a function depends on balances, ownership, claim status, or quotas, update those values before any external interaction. This prevents the same action from being repeated under stale state.

3. Keep external calls at the edge

A good pattern is to isolate external interactions at the end of a function, after all internal accounting is complete. This makes it easier to audit and reason about safety.

4. Treat callbacks as untrusted

If you integrate with standards that include hooks or callbacks, assume the callee can reenter. ERC777, some NFT receiver flows, and cross-protocol integrations often require extra caution.

5. Prefer pull-based settlement for value transfers

If a contract owes funds to users, record the debt and let users withdraw later. This reduces the number of places where Ether is sent during sensitive state transitions.


A practical escrow example

Suppose you are building a simple escrow that holds deposits until a release condition is met.

A naive implementation might transfer funds directly when the seller confirms delivery. A safer design records the release and lets the buyer withdraw.

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

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Escrow is ReentrancyGuard {
    address public immutable buyer;
    address public immutable seller;
    uint256 public amount;
    bool public funded;
    bool public released;

    constructor(address _buyer, address _seller) {
        buyer = _buyer;
        seller = _seller;
    }

    function fund() external payable {
        require(msg.sender == buyer, "only buyer");
        require(!funded, "already funded");
        amount = msg.value;
        funded = true;
    }

    function release() external nonReentrant {
        require(msg.sender == buyer, "only buyer");
        require(funded, "not funded");
        require(!released, "already released");

        released = true;

        (bool ok, ) = payable(seller).call{value: amount}("");
        require(ok, "payment failed");
    }
}

This contract is still simple, but it demonstrates the key principle: once released is set, a reentrant call cannot trigger another payout.


Common mistakes to avoid

Protecting only the obvious function

Attackers often exploit the function you did not think was dangerous. If one function can indirectly trigger a payout or state mutation, it may also need protection.

Assuming transfer() is always safe

Older Solidity guidance recommended transfer() because it forwards limited gas. That is no longer a universal defense, and it can break compatibility with contracts that need more gas in their receive logic. Modern code typically uses call, which means you must explicitly handle reentrancy risk.

Forgetting cross-function reentrancy

A guard on one function does not automatically protect a different function that touches the same state. If withdraw() is protected but claimReward() can also drain the same balance mapping, both may need protection or coordinated design.

Relying on view assumptions

A function that looks read-only at the Solidity level may still interact with external contracts that have unexpected behavior. Be careful when calling into untrusted dependencies, even in code paths that seem informational.


Testing reentrancy defenses

Security-sensitive code should be tested with malicious contracts, not just normal users. A good test suite should include:

  • a contract that reenters via receive()
  • a contract that reenters through a token callback
  • repeated attempts to exploit the same function
  • cross-function reentry attempts
  • failure cases where the external call reverts

A minimal attacker contract can look like this:

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

interface IVault {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract ReentrancyAttacker {
    IVault public vault;
    uint256 public targetAmount;
    bool public attacking;

    constructor(address _vault) {
        vault = IVault(_vault);
    }

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

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

If the vault is vulnerable, the attacker may withdraw more than it deposited. If the vault is protected correctly, the second call should revert.


Best practices for production contracts

  • Use ReentrancyGuard on any function that transfers value or calls external contracts during sensitive state changes.
  • Apply checks-effects-interactions consistently.
  • Prefer pull-based withdrawals for user payouts.
  • Audit all functions that share the same state, not just the most obvious payout function.
  • Test with malicious callback contracts.
  • Keep external calls isolated and easy to review.
  • Document which functions are intentionally nonReentrant so future maintainers do not accidentally bypass the protection.

Reentrancy is fundamentally about control flow, so the safest contracts are the ones that make their state transitions explicit and their external interactions deliberate.

Learn more with useful resources