Why ERC-20 allowances deserve special attention

The ERC-20 standard separates token ownership from spending permission. A token holder can authorize another address to spend tokens on their behalf by setting an allowance. That spender can then call transferFrom up to the approved amount.

This design is simple, but the operational reality is more complex:

  • Allowances can be overwritten, not incrementally updated by default.
  • Some tokens do not strictly follow the standard.
  • Front-end approval flows can race with on-chain state changes.
  • Unlimited approvals improve UX but increase risk if the spender contract is compromised.
  • Contracts that depend on allowances often need defensive checks to avoid confusing failures.

If you are writing a token, a vault, a router, or any contract that consumes ERC-20 tokens, you should treat allowance handling as part of your contract’s security and UX design, not just a boilerplate integration detail.

The core ERC-20 allowance model

The standard allowance flow has three actors:

  • Token owner: the account that holds tokens
  • Spender: the contract or address allowed to spend tokens
  • Token contract: the ERC-20 implementation that tracks balances and allowances

The owner calls:

approve(spender, amount)

Then the spender can call:

transferFrom(owner, recipient, amount)

The token contract checks that the allowance is sufficient and reduces it after the transfer.

Important behavior to remember

The allowance is usually stored as a mapping like:

mapping(address => mapping(address => uint256)) public allowance;

The first key is the token owner, and the second is the spender. This means allowances are per-owner, per-spender, and per-token contract. They are not global permissions.

The classic approval race condition

One of the best-known ERC-20 pitfalls is the approval race condition. It occurs when a user wants to change an existing allowance from one value to another.

Suppose Alice has approved Bob for 100 tokens and wants to reduce it to 50. If she simply submits:

approve(bob, 50)

there is a window where Bob may front-run the transaction and spend the full 100 before the new approval is mined. Then, after the approval lands, Bob still has 50 more available. In effect, Bob may spend 150 total.

This is not a bug in Solidity syntax; it is a consequence of how allowance updates work.

Safer update pattern

The traditional mitigation is to first set the allowance to zero, then set the new value:

approve(bob, 0);
approve(bob, 50);

This reduces the chance of a race during allowance changes, though it does not eliminate all UX friction because it requires two transactions in many wallets.

Designing contracts that consume allowances safely

If your contract pulls tokens from users, your goal is to make the allowance flow predictable and easy to reason about.

Prefer pull-based deposits

A common and safe pattern is:

  1. User approves your contract.
  2. User calls your contract’s deposit function.
  3. Your contract calls transferFrom to pull the tokens.

Example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract SimpleVault {
    IERC20 public immutable token;

    mapping(address => uint256) public deposits;

    constructor(address tokenAddress) {
        token = IERC20(tokenAddress);
    }

    function deposit(uint256 amount) external {
        require(amount > 0, "amount=0");

        bool ok = token.transferFrom(msg.sender, address(this), amount);
        require(ok, "transferFrom failed");

        deposits[msg.sender] += amount;
    }
}

This pattern is straightforward, but it should be improved in production with safer ERC-20 handling, which we will cover shortly.

Validate the amount before pulling

Always validate user input before calling transferFrom. This avoids unnecessary token calls and makes reverts easier to understand.

Good checks include:

  • amount > 0
  • amount <= maxDeposit
  • user-specific limits
  • pause or whitelist conditions

Avoid assuming transferFrom always returns true

Not all tokens behave consistently. Some return false on failure, some revert, and some older tokens do not return a boolean at all. If you call ERC-20 methods directly, your contract may break with non-standard tokens.

A robust approach is to use a safe wrapper library such as OpenZeppelin’s SafeERC20, which handles many of these inconsistencies.

Using SafeERC20 for more reliable token interactions

When your contract interacts with third-party tokens, SafeERC20 is usually the right default. It wraps calls and treats missing return values more defensively.

Example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract SafeVault {
    using SafeERC20 for IERC20;

    IERC20 public immutable token;

    mapping(address => uint256) public deposits;

    constructor(IERC20 tokenAddress) {
        token = tokenAddress;
    }

    function deposit(uint256 amount) external {
        require(amount > 0, "amount=0");
        token.safeTransferFrom(msg.sender, address(this), amount);
        deposits[msg.sender] += amount;
    }
}

Why this matters

SafeERC20 helps you avoid:

  • silent failures from tokens that return false
  • compatibility issues with tokens that do not return a value
  • brittle low-level call handling in every contract

For most production contracts, this is not optional; it is a baseline integration practice.

Unlimited approvals: convenience versus risk

Many dApps ask users to approve an effectively unlimited amount, often type(uint256).max, so they do not need to request approval again for each action.

This improves UX, but it also creates a larger blast radius if the spender contract is compromised or misconfigured.

When unlimited approvals make sense

Unlimited approvals are reasonable when:

  • the spender is a well-audited, immutable protocol
  • the user is expected to interact repeatedly
  • the token is used in a high-frequency workflow, such as trading or vault rebalancing

When they are risky

Avoid encouraging unlimited approvals when:

  • the spender is upgradeable and controlled by a small admin set
  • the contract is experimental or newly deployed
  • the user only needs a one-time transfer
  • the spender address may change over time

Practical recommendation

If your application can support it, prefer exact approvals for one-off actions and bounded approvals for recurring actions. This gives users a clearer security model.

A comparison of common allowance strategies

StrategyUXSecurityBest use case
Exact approval per actionLowerHigherOne-time deposits, low-frequency operations
Bounded approvalMediumMediumRepeated but limited interactions
Unlimited approvalHighestLowerTrusted protocols with frequent usage
Zero-then-set updateLowerHigherChanging an existing allowance safely

The right choice depends on your protocol’s trust model and user behavior. A good product often supports more than one option.

Handling allowance checks inside your contract

Sometimes developers try to check allowances before calling transferFrom:

require(token.allowance(msg.sender, address(this)) >= amount, "insufficient allowance");

This can improve error messages, but it should not replace the actual token transfer check. The allowance may change between the check and the transfer, especially in multi-transaction workflows.

Use checks for UX, not for security

Allowance pre-checks are useful for:

  • custom revert messages
  • front-end validation
  • early failure before expensive logic

But the real enforcement must still happen at transferFrom.

Supporting permit-based approvals

A modern alternative to the classic two-transaction approval flow is EIP-2612 permit. It allows users to sign an approval off-chain and submit it with the action in a single transaction.

This is especially useful when:

  • you want to reduce wallet friction
  • the user is interacting with your contract for the first time
  • you want to avoid a separate approval transaction

Typical flow

  1. User signs a permit message.
  2. Your contract submits the permit and the action together.
  3. The token contract records the allowance.
  4. Your contract pulls the tokens.

This can dramatically improve UX, but it only works with tokens that implement permit.

Design tip

If your protocol depends on token deposits, consider supporting both:

  • deposit(amount) for standard approvals
  • depositWithPermit(...) for permit-enabled tokens

That gives users flexibility without forcing one token standard.

Example: deposit with permit support

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract PermitVault {
    using SafeERC20 for IERC20;

    IERC20 public immutable token;

    mapping(address => uint256) public deposits;

    constructor(IERC20 tokenAddress) {
        token = tokenAddress;
    }

    function deposit(uint256 amount) external {
        require(amount > 0, "amount=0");
        token.safeTransferFrom(msg.sender, address(this), amount);
        deposits[msg.sender] += amount;
    }

    function depositWithPermit(
        uint256 amount,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        require(amount > 0, "amount=0");

        IERC20Permit(address(token)).permit(
            msg.sender,
            address(this),
            amount,
            deadline,
            v,
            r,
            s
        );

        token.safeTransferFrom(msg.sender, address(this), amount);
        deposits[msg.sender] += amount;
    }
}

This pattern is clean, but note that it assumes the token supports EIP-2612. In production, you may want to detect support off-chain or provide separate UI paths.

Common mistakes to avoid

1. Resetting approvals inside the contract

A contract cannot generally “approve on behalf of the user” unless it owns the tokens. The user must grant the allowance from their own address.

2. Assuming all tokens behave the same

Tokens may have transfer fees, rebasing behavior, or non-standard return values. Test your contract against realistic token implementations, not only a vanilla mock.

3. Using approve as a substitute for access control

An allowance is not the same as authorization for arbitrary protocol actions. If a contract can be called by anyone, do not rely on token approvals as your only gate.

4. Forgetting to handle leftover allowances

If a user approves more than needed, the remaining allowance persists. Your UI should clearly show current allowance and encourage revocation when appropriate.

5. Ignoring spender address changes

If your protocol uses multiple routers, proxies, or upgrade paths, users may approve the wrong address. Make the spender address explicit in the UI and documentation.

Best practices for production systems

A robust allowance design usually includes the following:

  • Use SafeERC20 for all token transfers.
  • Prefer exact or bounded approvals when possible.
  • Support permit for better UX where available.
  • Show current allowance in the UI before requesting a new approval.
  • Recommend zero-then-set updates when changing an existing approval.
  • Test against standard and non-standard ERC-20 tokens.
  • Document the spender address clearly.
  • Treat unlimited approvals as a deliberate trade-off, not a default assumption.

A practical checklist for developers

Before shipping a contract that pulls tokens, verify:

  • The contract uses safe token wrappers.
  • Deposit and withdrawal flows are explicit.
  • Reverts are understandable and occur early.
  • Allowance-dependent actions cannot be partially completed in an unsafe state.
  • The front end explains why approval is needed.
  • Permit support is available if the target token supports it.
  • Users can revoke or reduce approvals without breaking core functionality.

Conclusion

ERC-20 allowances are simple in theory but nuanced in practice. The safest designs are the ones that make approval behavior explicit, minimize unnecessary trust, and handle token quirks defensively. If you build with predictable allowance flows, your contracts will be easier to integrate, easier to audit, and less likely to surprise users.

Learn more with useful resources