/ Writing · · Updated · 7 min read
ERC-20 approval frontrunning: how a changed allowance gets spent twice
By Oussama Allamou · Solidity · ERC-20 · Security · Frontrunning
ERC-20 approve does not adjust an allowance, it overwrites it. If you change an allowance from one non-zero value to another, the spender can see your pending transaction, spend the old allowance before it lands, and then spend the new one too. The fix is to never go from non-zero to non-zero directly: change allowances by a delta, or set them to zero and confirm before setting the new value.
Play the race step by step. Time runs downward. Use the buttons, the arrow keys, or tap the diagram.
- Step 01Alice approves 10 000
Alice calls approve(bob, 10 000). The token contract stores one number: how much Bob may still pull from her balance.
- Step 02Alice changes her mind
She decides 1 000 is enough and sends approve(bob, 1 000). On Ethereum mainnet that transaction waits in the public mempool, where anyone can read it before it is included.
- Step 03Bob sees it coming
Bob watches the mempool. He reads Alice's pending approval and prepares a transferFrom for the full 10 000 with a higher fee, so block builders place it ahead of hers.
- Step 04Bob's transfer lands first
The old allowance is still in place, so the transfer is valid. 10 000 tokens move to Bob and the allowance drops to 0.
- Step 05Alice's approval lands second
approve does not adjust the allowance, it overwrites it. The contract has no idea Bob just spent the old one. The allowance is set to a fresh 1 000.
- Step 06Bob spends again
Bob calls transferFrom for the new 1 000. He ends up with 11 000 tokens. Alice wanted his total to be 1 000.
What is approval frontrunning?
An ERC-20 allowance is one number per owner and spender pair: how many tokens the spender may still move out of the owner's balance with transferFrom. The owner sets it with approve(spender, amount).
The race from the diagram, in order:
- Alice has approved Bob for 10 000 tokens.
- Alice sends
approve(bob, 1 000)to lower it. The transaction is pending. - Bob sees it and sends
transferFrom(alice, bob, 10 000)with a higher fee. - Bob's transfer is included first. He receives 10 000 and the allowance is 0.
- Alice's approval is included. The allowance is now 1 000.
- Bob calls
transferFrom(alice, bob, 1 000).
Bob holds 11 000 of Alice's tokens. Alice intended a ceiling of 1 000.
Bob has to be someone who can act on his own: an externally owned account, or a contract whose owner can trigger transferFrom at will. A contract with fixed logic that only pulls tokens when you call it yourself cannot decide to race you.
Why does the token contract allow this?
Because both functions do exactly what the standard says. Stripped down, they look like this:
function approve(address spender, uint256 amount) external returns (bool) {
// Overwrites. Does not look at what was there, or at what was already spent.
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
// Only checks the allowance as it is right now.
allowance[from][msg.sender] -= amount; // reverts on underflow in 0.8+
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
approve sets a value. It has no way to express "lower it to 1 000 only if nothing has been spent since I looked". The contract sees two valid transactions and executes them in the order the block gives them. On a chain with a public mempool, the party who pays more gets to pick that order. It is the same lever a sandwich bot uses against a swap with a loose slippage floor.
How do you change an allowance safely?
Change it by a delta. In a contract, use OpenZeppelin's SafeERC20:
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract Treasury {
using SafeERC20 for IERC20;
function lower(IERC20 token, address spender, uint256 by) external {
// Reverts if the current allowance is smaller than `by`.
token.safeDecreaseAllowance(spender, by);
}
}
Replay the race with this. Alice asks to decrease by 9 000. If Bob spends the 10 000 first, the allowance is 0, the decrease reverts, and Bob never gets the extra 1 000. He still got the 10 000 he was approved for, which no fix can take back, but the two approvals no longer add up.
- Case 01Alice sends approve(bob, 1 000)
approve overwrites. After Bob has pulled the old 10 000, it hands him a fresh 1 000. Bob ends with 11 000.
- Case 02Alice sends safeDecreaseAllowance(bob, 9 000)
A decrease is relative to what is there. After Bob has pulled the 10 000 the allowance is 0, so the decrease reverts and nothing new is granted. Bob ends with 10 000.
Note that OpenZeppelin Contracts 5.0 removed increaseAllowance and decreaseAllowance from the ERC-20 token itself, because they were never part of the standard. The SafeERC20 helpers are the supported way to get the same behavior from the calling side.
Set it to zero first, then check. Send approve(spender, 0), wait for it to be included, look at whether the spender moved anything in the meantime, and only then send the new value. Without the check in the middle this is the same race in two transactions. Some tokens enforce the first half: USDT on Ethereum reverts when you try to go from one non-zero allowance to another.
Approve the exact amount, when it is needed. If a contract approves exactly what the next call will pull, the allowance is back to zero when the call ends. There is no leftover to change later and nothing to race for.
What about unlimited approvals?
Many apps ask for an allowance of type(uint256).max so you only approve once. It removes this race, because you never change the value, and replaces it with a larger exposure: if the approved contract has a bug or is upgraded into something hostile, everything in your wallet for that token is reachable, with no further signature from you.
For a wallet you care about, approve amounts you are about to use, and review what you have left open. revoke.cash and the token approval checker on Etherscan list your standing allowances and let you set them back to zero.
What do the terms mean?
- Allowance: the amount of an owner's tokens a spender may still transfer, stored in the token contract.
- approve: the ERC-20 function that sets an allowance to a given value.
- transferFrom: the ERC-20 function a spender calls to move tokens out of the owner's balance, reducing the allowance.
- Mempool: the set of transactions that have been broadcast but not yet included in a block.
- Frontrunning: getting a transaction included ahead of a pending one after seeing it, usually by paying a higher fee.
- SafeERC20: an OpenZeppelin library that wraps ERC-20 calls, including helpers that change an allowance by a delta.
FAQ
Is the approval race a bug in the token contract?
It is a property of the ERC-20 interface itself. A token that implements approve exactly as the standard describes has it. The standard's own text warns about it and suggests interfaces set the allowance to zero before setting a new value.
Does setting the allowance to zero first fix it?
Only if you check what happened in between. The spender can still spend the old allowance ahead of your zero. Setting to zero gives you a moment to see that before you grant anything new. If you send both transactions blindly, the spender ends up with both amounts.
Can any spender do this to me?
No. The spender has to be able to call transferFrom on their own initiative. An account controlled by a person or a bot can. A contract with fixed logic that only pulls tokens inside a function you call cannot decide to do it.
Does this happen on chains without a public mempool?
The attack depends on the spender seeing your pending approval and getting ordered ahead of it. Where pending transactions are not publicly visible that is much harder, but whoever orders transactions can still do it. Do not rely on the chain for this. Use the safe patterns.
Are unlimited approvals safer because the value never changes?
They avoid this specific race and expose your whole balance of that token to the approved contract for as long as the approval stands. That is a trade, not a fix.
Further reading
- ERC-20 standard (EIP-20): the
approvesection contains the original warning. - OpenZeppelin SafeERC20
- revoke.cash: review and revoke standing approvals.