
Solidity Reentrancy Guards: Designing Reentry-Resistant Contract Flows
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:
- The contract checks that the user has enough balance.
- It sends Ether to the user.
- 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:
- Checks-effects-interactions: state is updated before the external call.
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
nonReentrantentry 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
| Strategy | What it does | Best for | Limitations |
|---|---|---|---|
| Checks-effects-interactions | Updates state before external calls | Most fund-handling functions | Easy to forget in complex flows |
ReentrancyGuard | Blocks nested entry into protected functions | High-risk external-call paths | Cannot protect internal logic by itself |
| Pull payments | Lets users withdraw funds themselves | Escrow and payouts | Not always suitable for synchronous workflows |
| Minimal external calls | Reduces attack surface | General contract design | Not 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
ReentrancyGuardon 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
nonReentrantso 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.
