The ERC-20 allowance race condition was documented in 2017 — before most of the current DeFi ecosystem existed — and it has never been fixed in the standard itself. EIP-20 still specifies approve(address spender, uint256 amount) with no protection against front-running. The community workaround, increaseAllowance and decreaseAllowance, was added to OpenZeppelin's implementation but never standardized. That gap, combined with five related vulnerability classes, means allowance management remains one of the most misunderstood and underaudited areas of ERC-20 integration.
This post covers all six vulnerability patterns: the classic front-run attack, infinite approvals to compromised contracts, missing allowance checks in custom transferFrom overrides, leftover allowance after protocol operations, delegatecall storage collisions that bypass allowance logic, and the edge case of approving the zero address. For each, the attack path and the correct fix are shown in Solidity.
1. The Classic ERC-20 Approve Race Condition
The race condition arises whenever a token owner wants to change an existing non-zero allowance to a new non-zero value. The ERC-20 standard provides no atomic way to do this.
The attack sequence:
- Alice has approved Bob for 100 tokens. Bob's current allowance is 100.
- Alice decides to change the allowance to 200. She broadcasts
approve(bob, 200). - Bob sees Alice's transaction in the mempool before it confirms.
- Bob immediately broadcasts
transferFrom(alice, bob, 100)with a higher gas price. - Bob's front-run confirms first. He drains the original 100 tokens.
- Alice's
approve(bob, 200)confirms. Bob's allowance is now 200. - Bob calls
transferFrom(alice, bob, 200)again. He drains another 200 tokens. - Bob received 300 tokens total — 100 from the old allowance, 200 from the new.
// VULNERABLE: changing a non-zero allowance directly
contract VulnerableApproval {
// User calls this to update spender's limit
function updateAllowance(address token, address spender, uint256 newAmount) external {
// If spender frontruns before this confirms, they can spend both
// the old allowance and the new allowance
IERC20(token).approve(spender, newAmount);
}
}
The fix has two parts. First, use increaseAllowance / decreaseAllowance when adjusting relative to the current amount. Second, when setting an absolute value, always zero out the allowance before setting the new one.
// CORRECT: zero-first pattern for absolute allowance changes
function updateAllowanceSafe(address token, address spender, uint256 newAmount) external {
IERC20(token).approve(spender, 0);
IERC20(token).approve(spender, newAmount);
}
// PREFERRED: use increaseAllowance / decreaseAllowance when possible
// These operate on deltas, so there is no intermediate state to exploit
function grantAdditional(address token, address spender, uint256 extra) external {
// OpenZeppelin ERC20 exposes this — use it
ERC20(token).increaseAllowance(spender, extra);
}
Note that increaseAllowance itself is not immune to all race conditions — it only eliminates the specific scenario where the spender front-runs an absolute approve. An attacker who can insert transactions between increaseAllowance and transferFrom in the same block is still a concern in adversarial contexts.
2. Infinite Allowance to a Compromised Contract
Many DeFi frontends prompt users to approve type(uint256).max tokens to a protocol contract for gas convenience. The approval persists indefinitely in the token's storage — it does not expire, it does not shrink as tokens are spent (in most implementations), and it remains valid even after the protocol is upgraded or compromised.
// VULNERABLE: user approves infinite allowance to a DeFi router
// This approval persists even after the router is replaced or exploited
IERC20(token).approve(address(router), type(uint256).max);
// Later — router is upgraded via a proxy to a malicious implementation:
// Attacker calls routerV2.drainApprovals(victim, token)
// Since victim's allowance to the proxy address is still max, all tokens are gone
The attack path does not require any exploit in the token itself. The token contract is operating exactly as designed. The problem is that approvals are permanent grants to an address, and the behavior of contracts at that address can change through upgrades, ownership transfers, or governance attacks.
// SAFER: approve only the exact amount needed for one operation
function depositExact(address token, address protocol, uint256 amount) external {
// Approve exactly what is needed, not more
IERC20(token).approve(protocol, amount);
IProtocol(protocol).deposit(token, amount);
// Remaining allowance is zero (assuming deposit spends the full amount)
}
// BEST: revoke after use if the protocol supports it
function depositAndRevoke(address token, address protocol, uint256 amount) external {
IERC20(token).approve(protocol, amount);
IProtocol(protocol).deposit(token, amount);
// Explicitly revoke any leftover allowance
IERC20(token).approve(protocol, 0);
}
EIP-6492 and EIP-5792 propose time-limited and scoped approvals at the wallet layer, but these have not been adopted at the token standard level. Until they are, the correct practice is exact-amount approvals revoked after use, or permit-based single-use authorizations where the token supports EIP-2612.
3. ERC-20 transferFrom Without Allowance Check
Custom ERC-20 implementations that override transferFrom sometimes forget to verify the caller's allowance. This is particularly common in tokens that add custom logic — fee-on-transfer mechanics, vesting schedules, or whitelisted transfer paths — and accidentally skip the allowance enforcement in the process.
// VULNERABLE: custom transferFrom that forgets to check allowance
contract BrokenToken is ERC20 {
mapping(address => bool) public whitelisted;
// Developer intended to bypass allowance for whitelisted callers
// but accidentally bypassed it for everyone
function transferFrom(
address from,
address to,
uint256 amount
) public override returns (bool) {
// Missing: require(allowance[from][msg.sender] >= amount)
// Missing: allowance[from][msg.sender] -= amount
// Any address can drain any holder's balance
_transfer(from, to, amount);
return true;
}
}
Any address can call transferFrom(victim, attacker, victimBalance) and drain the victim without any prior approve. The token balance mapping is written correctly, but the permission gate is missing entirely.
// CORRECT: always call super.transferFrom() or reproduce allowance logic fully
contract FixedToken is ERC20 {
mapping(address => bool) public whitelisted;
function transferFrom(
address from,
address to,
uint256 amount
) public override returns (bool) {
if (!whitelisted[msg.sender]) {
// Standard allowance check and decrement
uint256 currentAllowance = allowance(from, msg.sender);
require(currentAllowance >= amount, "ERC20: insufficient allowance");
unchecked {
_approve(from, msg.sender, currentAllowance - amount);
}
}
_transfer(from, to, amount);
return true;
}
}
The safest approach is to override only the specific behavior you need to change and delegate all allowance logic to OpenZeppelin's audited base implementation via super.transferFrom().
4. Allowance Not Reset After Protocol Operation
When a protocol performs a token operation on behalf of a user and the user has approved more than the exact amount consumed, leftover allowance accumulates silently. A future upgrade, governance action, or attacker who takes control of the protocol contract can exploit this residual allowance.
// VULNERABLE: protocol uses partial allowance and leaves the rest
contract VulnerableSwapRouter {
function swap(
address tokenIn,
uint256 amountIn,
address tokenOut,
uint256 minAmountOut
) external {
// User approved 1000 tokens. Router only pulls 800 for the swap.
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
uint256 amountOut = _executeSwap(tokenIn, amountIn, tokenOut);
require(amountOut >= minAmountOut, "Slippage");
IERC20(tokenOut).transfer(msg.sender, amountOut);
// 200 tokens of allowance remain — never reset
}
}
If the router is later compromised, the attacker can call transferFrom for each user whose allowance was not fully consumed. This is especially dangerous in protocols that encourage over-approval for gas savings.
// CORRECT: reset residual allowance after the operation
contract SecureSwapRouter {
function swap(
address tokenIn,
uint256 amountIn,
address tokenOut,
uint256 minAmountOut
) external {
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
uint256 amountOut = _executeSwap(tokenIn, amountIn, tokenOut);
require(amountOut >= minAmountOut, "Slippage");
IERC20(tokenOut).transfer(msg.sender, amountOut);
// Reset any remaining allowance from caller to this contract
uint256 remaining = IERC20(tokenIn).allowance(msg.sender, address(this));
if (remaining > 0) {
// Use safeApprove(0) or guide users to use permit for single-use approvals
IERC20(tokenIn).safeApprove(msg.sender, 0);
}
}
}
The cleaner long-term solution is permit (EIP-2612): the user signs an exact-amount, single-use authorization off-chain, and the protocol calls permit immediately before transferFrom. No residual allowance can accumulate because the permit is consumed in the same transaction.
5. Double-Spend via delegatecall on Allowance Storage
ERC-20 tokens that use delegatecall to external libraries for transfer logic introduce a storage layout dependency. The allowance mapping lives in the main contract's storage, but the library code executes in that context and reads slot positions it expects to find the mapping at. If the library's expected slot does not match the actual slot — because the main contract added a variable, changed inheritance order, or was refactored — the allowance check reads garbage data.
// VULNERABLE: main token delegates to a library that reads allowances from a hardcoded slot
contract DelegatingToken {
mapping(address => uint256) public balances; // slot 0
mapping(address => mapping(address => uint256)) public allowances; // slot 1
address public transferLib;
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
// Library executes in this contract's storage context
(bool ok,) = transferLib.delegatecall(
abi.encodeWithSignature("transferFrom(address,address,uint256)", from, to, amount)
);
require(ok, "delegatecall failed");
return true;
}
}
// In a refactored version, a new variable shifts allowances to slot 2:
contract RefactoredDelegatingToken {
address public owner; // slot 0 (new)
mapping(address => uint256) public balances; // slot 1
mapping(address => mapping(address => uint256)) public allowances; // slot 2
address public transferLib;
// Library still reads slot 1 expecting allowances — it reads balances instead
// Allowance check is bypassed; attacker can transfer without approval
}
This class of vulnerability has appeared in proxy-based token implementations and upgrade patterns where the storage layout was modified between versions without updating the library.
// CORRECT: inline all allowance logic — never delegatecall for allowance-sensitive paths
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 currentAllowance = allowances[from][msg.sender];
require(currentAllowance >= amount, "ERC20: insufficient allowance");
unchecked {
allowances[from][msg.sender] = currentAllowance - amount;
}
_transfer(from, to, amount);
return true;
}
If you must use delegatecall for extensibility, use a storage layout verification step in your upgrade process and pin storage slot positions with inline assembly assertions or a dedicated storage layout test.
6. approve() to the Zero Address
Some ERC-20 implementations allow approve(address(0), amount), recording an allowance entry for the zero address in the allowances mapping. Since no private key controls address(0), transferFrom can never be called by that address on a standard EVM chain, making this superficially harmless.
// ERC-20 that does not guard against zero-address approval
function approve(address spender, uint256 amount) public returns (bool) {
// No require(spender != address(0)) check
allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
// This call succeeds and records allowances[caller][address(0)] = 1000e18
token.approve(address(0), 1000e18);
The direct threat is low. The secondary threat is real: protocols that enumerate all Approval events to compute whether a user has an outstanding infinite approval will include zero-address approvals in their count. A dashboard that warns "you have 2 dangerous infinite approvals" when one of them is to address(0) produces false positives. More dangerous is a protocol that reads allowance(user, address(0)) as a sentinel for some custom logic — any nonzero value produces unexpected behavior.
// CORRECT: guard against zero-address in approve, as OpenZeppelin does
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "ERC20: approve to the zero address");
allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
// Protocol-side sanitization when reading allowances from untrusted tokens
function getSafeAllowance(address token, address owner, address spender) internal view returns (uint256) {
if (spender == address(0)) return 0;
return IERC20(token).allowance(owner, spender);
}
OpenZeppelin's ERC-20 implementation includes the zero-address guard in _approve. Any custom implementation that does not inherit from OpenZeppelin must add it manually.
What ContractScan Detects
| Vulnerability | Detection Method | Severity |
|---|---|---|
approve() race condition on non-zero allowance |
Dataflow analysis: approve(spender, nonZeroValue) when prior allowance may be nonzero without intermediate zero-reset |
High |
| Infinite allowance to upgradeable contract | Static + semantic: approve(addr, type(uint256).max) where addr is a proxy or has an owner that can change implementation |
High |
transferFrom override missing allowance check |
AST analysis: transferFrom override that does not call super.transferFrom and does not reference allowance or _approve |
Critical |
| Residual allowance not reset after protocol operation | Taint tracking: transferFrom with user-sourced from where allowance is not zeroed post-operation |
Medium |
delegatecall storage slot collision on allowance |
Storage layout analysis: delegatecall in token path with mismatched slot positions between caller and library |
Critical |
approve(address(0), ...) allowed |
Pattern match: absence of require(spender != address(0)) or equivalent guard in approve or _approve |
Low |
Audit your ERC-20 allowance logic at https://contractscan.io — ContractScan's AI engine traces allowance flows across your entire codebase, identifies missing guards, leftover approvals, and storage collisions, and generates a prioritized finding report in seconds.
Related Posts
- ERC-20 Missing Return Value: Why safeTransfer Exists and What Breaks Without It
- EIP-2612 Permit Security: Signature Phishing, Front-Running, and Permit Misuse
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.