Understanding the ERC-20 allowance model

The ERC-20 standard uses a two-step authorization pattern:

  1. The token owner calls approve(spender, amount).
  2. The spender later calls transferFrom(owner, recipient, amount).

The allowance is stored on-chain as a number representing how many tokens the spender may move on behalf of the owner. This design is simple and composable, but it has a well-known weakness: changing an allowance is not atomic with respect to the spender’s use of that allowance.

Why the race condition exists

Suppose Alice has approved Bob to spend 100 tokens. She wants to reduce Bob’s allowance to 20.

A dangerous sequence looks like this:

  1. Alice submits approve(Bob, 20).
  2. Bob sees the transaction in the mempool.
  3. Bob submits transferFrom(Alice, Bob, 100) with a higher gas price.
  4. Bob’s transfer is mined first, consuming the old allowance.
  5. Alice’s approval is mined afterward, setting a fresh allowance of 20.

Bob may now have spent 100 tokens and still has 20 more available. The exact outcome depends on transaction ordering, but the core issue is that the allowance update and the allowance use are separate state transitions.

The unsafe approval pattern

A common mistake is to overwrite an allowance directly from one non-zero value to another non-zero value.

function updateSpenderAllowance(address spender, uint256 amount) external {
    token.approve(spender, amount);
}

This looks harmless, but if the spender can front-run the transaction, they may use the old allowance before the new one takes effect. The problem is especially visible when reducing allowances, but it can also affect increases.

The classic ERC-20 warning

Many ERC-20 implementations and integrations recommend a safer pattern:

  • first set the allowance to 0
  • then set the new allowance to the desired value

This reduces the window in which both old and new permissions can be exploited.

Safer approval strategies

There is no single universal fix, because the right approach depends on the token, the spender contract, and the user experience you want to support. The table below summarizes the most common strategies.

StrategySecurity propertiesTrade-offs
Set allowance to 0 before setting a new valuePrevents direct non-zero to non-zero replacement racesRequires two transactions or two calls
Use increaseAllowance / decreaseAllowanceUpdates are relative, reducing overwrite riskRequires token support
Use permit-based approvalsApprovals are signed off-chain and submitted onceRequires EIP-2612 support
Use exact, one-time approvalsLimits exposure to a single spendMore friction for users and dApps

Pattern 1: zero-then-set approval

If you control the approval flow in your application, the safest general-purpose pattern is to clear the existing allowance before assigning a new one.

function safeApprove(IERC20 token, address spender, uint256 amount) external {
    uint256 current = token.allowance(address(this), spender);

    if (current != 0) {
        require(token.approve(spender, 0), "reset failed");
    }

    require(token.approve(spender, amount), "approve failed");
}

When this helps

This pattern is useful when:

  • your contract is the token holder
  • you need to manage allowances programmatically
  • the token follows the standard ERC-20 approve behavior

Important caveat

Some ERC-20 tokens do not behave consistently. A few tokens require allowance to be set to zero before any change, while others may not return a boolean value at all. In production code, use a battle-tested library such as OpenZeppelin’s SafeERC20 to handle these edge cases.

Pattern 2: relative allowance updates

A safer API is to avoid direct overwrites altogether and use relative changes:

  • increaseAllowance(spender, delta)
  • decreaseAllowance(spender, delta)

This avoids the ambiguity of replacing one value with another. If the spender already has an allowance, the owner can increment or decrement it in a controlled way.

function grantMore(IERC20 token, address spender, uint256 delta) external {
    uint256 current = token.allowance(msg.sender, spender);
    require(token.approve(spender, current + delta), "increase failed");
}

The example above still uses approve internally, so it is not ideal for all tokens. In practice, prefer token contracts or libraries that expose explicit increase/decrease methods.

Why relative updates are safer

Relative updates reduce the chance of accidentally resetting permissions to an unintended value. They also make it easier to reason about the final allowance after multiple changes. However, they do not eliminate all race conditions if the spender is actively using the allowance at the same time.

Pattern 3: exact approvals for single use

For many dApps, the best design is to avoid long-lived allowances entirely. Instead, approve only the exact amount needed for a single operation, then spend it immediately.

Typical flow:

  1. User approves the exact amount.
  2. The protocol performs the transfer.
  3. The allowance is no longer needed.

This is especially effective for:

  • swaps
  • deposits
  • one-time vault interactions
  • minting flows that immediately pull tokens

Example: deposit contract that pulls exact funds

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

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

contract TokenVault {
    using SafeERC20 for IERC20;

    IERC20 public immutable asset;

    mapping(address => uint256) public deposits;

    constructor(IERC20 _asset) {
        asset = _asset;
    }

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

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

In this model, the user can approve only the amount they intend to deposit. If the allowance is consumed immediately, the attack surface is much smaller than with a persistent unlimited approval.

Unlimited approvals: convenience versus risk

Many applications ask users to grant an “infinite” allowance so they do not need to approve every transaction. This improves usability, but it also creates a large blast radius if the spender contract is compromised or misconfigured.

Risks of unlimited approvals

  • A bug in the spender contract can drain all approved tokens.
  • An upgrade or governance change can alter contract behavior unexpectedly.
  • A malicious UI can trick users into approving a dangerous spender.
  • A compromised private key controlling the spender can move all approved funds.

Best practice

Use unlimited approvals only when the spender contract is highly trusted and the user experience benefit is significant. For consumer-facing applications, consider:

  • exact approvals
  • short-lived approvals
  • permit-based flows
  • explicit revocation UX

Using permit to reduce approval friction

EIP-2612 introduces permit, which lets a user sign an approval off-chain. The spender then submits the signed approval and the token transfer in a single transaction. This can reduce the need for separate approval transactions and can shrink the window for race conditions.

Benefits of permit-based flows

  • fewer user transactions
  • better UX
  • less time between authorization and execution
  • easier atomic execution with the intended spend

Limitations

  • only works with tokens that implement permit
  • requires careful nonce handling
  • still depends on correct domain separation and signature validation

A permit flow is not a universal replacement for approvals, but it is often a better default when supported by the token.

Designing safer spender contracts

If your contract is the spender, your job is not only to pull tokens safely but also to minimize the consequences of allowance misuse.

Recommendations

  • Pull only the exact amount needed.
  • Avoid storing large residual allowances in the contract.
  • Do not assume an allowance remains valid after a user action.
  • Validate token addresses carefully if your contract supports multiple assets.
  • Prefer pull-based flows over push-based token transfers from users.

Example: consume allowance immediately

function executeTrade(IERC20 token, uint256 amount) external {
    token.safeTransferFrom(msg.sender, address(this), amount);

    // Process the tokens immediately.
    // Do not leave them idle under a large standing allowance.
}

This pattern reduces the amount of time a spender can exploit an allowance and keeps the authorization tightly coupled to the action.

Handling allowance revocation

Users often want to revoke permissions after using an application. Your UI and contract design should make revocation straightforward.

Good revocation UX

  • show current allowances clearly
  • provide a one-click revoke action
  • support setting allowance to zero
  • warn users about unlimited approvals
  • surface the exact spender address being approved

On-chain revocation pattern

function revoke(IERC20 token, address spender) external {
    require(token.approve(spender, 0), "revoke failed");
}

Revocation is only effective if the spender has not already used the allowance. That is why reducing exposure early is better than relying on revocation after the fact.

Common mistakes to avoid

1. Overwriting non-zero allowances directly

Setting a new allowance without clearing the old one first is the classic race condition.

2. Assuming approval means immediate transfer

Approval only grants permission. It does not guarantee the spender will use it in the order you expect.

3. Using unlimited approvals by default

This is often the easiest path for developers, but it is not the safest default for users.

4. Ignoring token quirks

Not all tokens strictly follow the same ERC-20 behavior. Some return false, some revert, and some have non-standard approval semantics.

5. Building UX that hides spender identity

Users should always know which contract is being approved and what amount is being authorized.

Practical checklist

Before shipping an ERC-20 integration, verify the following:

  • Do you really need a standing allowance?
  • Can the flow be redesigned to use exact, one-time approvals?
  • If changing an allowance, do you clear it to zero first?
  • Are you using SafeERC20 or equivalent defensive wrappers?
  • Do you support permit where available?
  • Can users revoke approvals easily?
  • Is the spender contract trusted enough to justify large approvals?

Conclusion

ERC-20 allowances are powerful, but they are also a frequent source of security bugs because they separate authorization from execution. The main defense is to reduce the time and scope of granted permissions. Prefer exact approvals, clear allowances before replacing them, use relative updates when supported, and avoid unlimited approvals unless the trust model truly justifies them.

For Solidity developers, secure allowance handling is less about a single function and more about designing the entire token flow so that permissions are narrow, short-lived, and easy to revoke.

Learn more with useful resources