Token approvals are the most common user action in DeFi — and one of the most dangerous. Approving a contract to spend your tokens seems routine. But the near-universal practice of requesting approve(spender, type(uint256).max) (infinite allowance) has been a factor in hundreds of millions of dollars of losses.
This post covers the problem from both directions: what users face, and what smart contract developers can do to make their protocols safer by design.
How Token Approvals Work
ERC-20's approve() function grants another address permission to spend up to a specified amount of your tokens:
// User calls this to let a DEX spend their USDC
IERC20(usdc).approve(dex, amount);
// DEX then calls this to actually move the tokens
IERC20(usdc).transferFrom(user, dex, amount);
The allowance mapping tracks what each spender is permitted to take:
mapping(address => mapping(address => uint256)) public allowance;
// allowance[owner][spender] = amount
The problem: once approved, the spender can call transferFrom at any time in the future, up to the approved amount.
Why Infinite Approvals Became Standard Practice
The two-transaction UX of "approve exact amount → spend" frustrated users. Every time you wanted to swap 100 USDC, you had to:
1. Submit approve(dex, 100e6) — wait for confirmation, pay gas
2. Submit swap(100e6) — wait for confirmation, pay gas
To avoid this, DeFi UX teams switched to requesting approve(dex, type(uint256).max) on first use. After that, the DEX can spend any amount forever — no re-approval needed.
This trades user experience for a permanent open door.
The Attack Surface: Three Ways Infinite Approvals Get Exploited
1. Protocol Contract Gets Exploited
The most common scenario: you approve a legitimate DeFi protocol, then that protocol gets hacked.
User approves ProtocolV1 for MAX_UINT256 USDC
↓
6 months later: ProtocolV1 exploit — attacker drains contract
↓
Attacker calls: ProtocolV1.exploit() → IERC20(usdc).transferFrom(user, attacker, MAX_UINT256)
↓
User's entire USDC balance is drained (even assets not in the protocol)
The key point: the user's tokens weren't in the protocol — they were in the user's wallet. But the standing infinite approval let the attacker drain them through the compromised protocol.
Real examples:
- Badger DAO exploit (2021, $120M): front-end compromise injected malicious approvals that allowed draining approved tokens
- Multiple DEX aggregator exploits: logic bugs in routing contracts that allowed calling transferFrom with attacker-controlled parameters
2. Malicious Contract Disguised as Legitimate
Phishing sites deploy contracts that look like real DeFi protocols. Users are tricked into calling approve(malicious_contract, MAX_UINT256). The attacker waits and drains later.
3. Deprecated/Upgraded Contract Still Has Approvals
Protocols upgrade their contracts but users' approvals for the old address remain. If the old contract has any vulnerability (even a minor one that wasn't worth patching before deprecation), it's now permanently dangerous.
Contract-Level Patterns for Safer Approvals
While users bear responsibility for managing their approvals, contract developers can significantly reduce the attack surface:
Pattern 1: Request Exact Amounts, Not MAX_UINT256
// BAD: requests unlimited approval from user
function depositForUser(address token, uint256 amount) external {
// Implies the UI will request approve(address(this), MAX_UINT256)
IERC20(token).transferFrom(msg.sender, address(this), amount);
}
// BETTER: use permit() for exact-amount, single-use approval
function depositWithPermit(
address token,
uint256 amount,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s);
IERC20(token).transferFrom(msg.sender, address(this), amount);
}
Using permit() (EIP-2612) means the approval is for exactly the amount needed and expires at deadline. No standing approval remains after the transaction.
Pattern 2: Decrease Allowance After Use
For protocols that must use traditional approvals, consume the entire approved amount or explicitly reduce it:
// After using tokens, reduce approval to zero
function processAndClear(address token, address user, uint256 amount) internal {
IERC20(token).transferFrom(user, address(this), amount);
// If there's remaining allowance and the protocol is done, clear it
// (Note: this requires the user to have approved the contract calling this)
}
Pattern 3: Validate transferFrom Parameters
A class of exploits uses malicious inputs to re-route transferFrom against user approvals. Always validate that from is the intended source:
// VULNERABLE: attacker can set `from` to any approved user
contract VulnerableRouter {
function swap(
address token,
address from, // ← attacker controls this
uint256 amount
) external {
IERC20(token).transferFrom(from, address(this), amount);
// ... swap logic
}
}
// SAFE: always use msg.sender as the source
contract SafeRouter {
function swap(address token, uint256 amount) external {
IERC20(token).transferFrom(msg.sender, address(this), amount);
// ... swap logic
}
}
Pattern 4: Use Callback-Based Approval (ERC-777 / Flash Callbacks)
ERC-777 tokens use a callback model where the token notifies the recipient, removing the need for pre-approval entirely. Flash loan callbacks follow a similar pattern: the protocol calls you, you act, and the result is checked atomically.
Pattern 5: Approve-and-Call in One Transaction
For protocols that own the token contract (e.g., governance tokens), implement approveAndCall:
// Approve + immediate consumption in one transaction
function approveAndCall(
address spender,
uint256 amount,
bytes calldata data
) external returns (bool) {
approve(spender, amount);
ISpenderCallback(spender).tokensReceived(msg.sender, amount, data);
// After callback, spender should have consumed the full amount
require(allowance[msg.sender][spender] == 0, "Allowance not fully consumed");
return true;
}
What Auditors Check for Approval Patterns
When auditing a protocol, look for:
High risk:
- transferFrom(from, ...) where from is a function parameter (caller-controlled) — allows draining any user with existing approval
- Logic that calls transferFrom conditionally (approval exists but only used in some branches) — creates dormant attack surface
- Contracts that accept and process MAX_UINT256 as a valid amount without special handling
Medium risk:
- Protocol holds user approvals across multiple transactions without a mechanism to revoke them
- Upgrade pattern that deploys a new contract but doesn't invalidate old approvals
Best practices present:
- Uses permit() instead of persistent approvals where possible
- Validates from == msg.sender in all transferFrom calls
- Documents approval requirements clearly so users know what they're approving
Detection: What Automated Scanners Find
| Issue | Slither | Semgrep | AI |
|---|---|---|---|
Unchecked transferFrom return value |
✅ | ✅ | ✅ |
transferFrom(from, ...) with controllable from |
⚠️ | ⚠️ | ✅ |
| Persistent approval with no revocation mechanism | ❌ | ❌ | ✅ |
Missing permit() usage in approval-heavy flows |
❌ | ❌ | ✅ |
Contracts that call approve(MAX_UINT256) internally |
⚠️ | ⚠️ | ✅ |
The pattern of "controllable from in transferFrom" is the most dangerous and also the hardest for static analysis to flag — it requires understanding the data flow from external caller to the transferFrom call. AI-based analysis can reason about this flow and flag it even across multiple function calls.
The Broader Picture: Approval Management
From a user perspective, the best mitigations are:
- Use revoke.cash or similar to audit and revoke unnecessary approvals
- Prefer protocols that use permit() for one-time approvals
- Never approve contracts you don't recognize
From a developer perspective: design your protocol so users never need to grant approvals larger than what your specific operation requires. Permit-based flows, flash callbacks, and approveAndCall patterns all achieve this.
Scan your contracts for approval-related vulnerabilities with ContractScan — the AI engine specifically flags controllable-from patterns and approves without revocation paths that static analysis typically misses.
Related: EIP-2612 permit() and signature replay attacks — the gasless approval alternative introduces its own security considerations.