← Back to Blog

ERC-1155 Multi-Token Security Vulnerabilities and How to Prevent Them

2026-04-18 erc1155 multi-token solidity security nft defi 2026

ERC-1155 is the most flexible token standard in Ethereum — a single contract can manage both fungible and non-fungible tokens, handle batch transfers of up to thousands of token IDs in one transaction, and minimize gas costs through efficient packing. It's also one of the most dangerous to implement incorrectly.

The vulnerability surface of ERC-1155 is fundamentally different from ERC-20 and ERC-721. The callback hooks are invoked multiple times during batch operations. The approval model grants permissions across all token IDs in a single call. Token IDs themselves become part of the attack surface. Contracts that mix fungible and non-fungible tokens in the same ID space can leak value between editions.

This post covers seven critical vulnerabilities unique to ERC-1155, with vulnerable code patterns, fixes, and detection guidance.


1. Reentrancy in onERC1155Received Callbacks

The most dangerous vulnerability in ERC-1155 is that safeTransferFrom() calls onERC1155Received() on the recipient before updating the contract's token ledger.

// Simplified ERC-1155 transfer flow (VULNERABLE ORDER)
function safeTransferFrom(
    address from,
    address to,
    uint256 id,
    uint256 amount,
    bytes calldata data
) external {
    // Step 1: call recipient hook — can reenter!
    if (to.code.length > 0) {
        require(
            IERC1155Receiver(to).onERC1155Received(
                msg.sender, from, id, amount, data
            ) == IERC1155Receiver.onERC1155Received.selector,
            "Invalid receiver"
        );
    }

    // Step 2: update balances — too late for reentrancy defense
    balances[id][from] -= amount;
    balances[id][to] += amount;
}

Attack scenario:

An attacker receives a token in onERC1155Received() while the ledger hasn't been updated yet. They call back into the contract to withdraw tokens that haven't been debited from their account.

// Attacker contract
contract ERC1155Attacker is IERC1155Receiver {
    ERC1155Vault vault;
    bool private reentering;

    function onERC1155Received(
        address operator,
        address from,
        uint256 id,
        uint256 amount,
        bytes calldata data
    ) external override returns (bytes4) {
        if (!reentering) {
            reentering = true;
            // Reenter: vault still thinks we haven't received tokens yet
            vault.withdraw(id, amount);
        }
        return this.onERC1155Received.selector;
    }
}

Fix: Checks-Effects-Interactions pattern

Update balances before calling the hook:

function safeTransferFrom(
    address from,
    address to,
    uint256 id,
    uint256 amount,
    bytes calldata data
) external {
    // Step 1: update balances FIRST
    balances[id][from] -= amount;
    balances[id][to] += amount;

    // Step 2: emit event
    emit TransferSingle(msg.sender, from, to, id, amount);

    // Step 3: call hook LAST
    if (to.code.length > 0) {
        require(
            IERC1155Receiver(to).onERC1155Received(
                msg.sender, from, id, amount, data
            ) == IERC1155Receiver.onERC1155Received.selector,
            "Invalid receiver"
        );
    }
}

OpenZeppelin's ERC-1155 implementation follows the correct order. Any custom implementation must do the same.


2. Reentrancy in safeBatchTransferFrom

Batch transfers multiply the reentrancy surface. safeBatchTransferFrom() can transfer up to thousands of token IDs, and the hooks are typically called after all balance updates.

However, if your implementation calls onERC1155BatchReceived() after balance updates (correct), but then immediately transfers another batch before returning, an attacker can still reenter during a single batch operation if they control the batch contents.

// VULNERABLE: Multiple calls without guard
function batchWithdraw(
    ERC1155 token,
    uint256[] calldata ids,
    uint256[] calldata amounts
) external {
    token.safeBatchTransferFrom(address(this), msg.sender, ids, amounts, "");

    // Attacker receives tokens in callback
    // While this transaction is still executing, they call batchWithdraw again
    // Second call transfers more tokens before the first call updates state
}

Fix: Use ReentrancyGuard for batch operations

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeBatchVault is ReentrancyGuard {
    function batchWithdraw(
        ERC1155 token,
        uint256[] calldata ids,
        uint256[] calldata amounts
    ) external nonReentrant {
        token.safeBatchTransferFrom(address(this), msg.sender, ids, amounts, "");
    }
}

Alternatively, use pull-over-push: let users claim tokens in a callback-free function rather than pushing them:

contract PullVault {
    mapping(address => mapping(uint256 => uint256)) public claims;
    ERC1155 public token;

    function claimTokens(uint256[] calldata ids) external {
        for (uint256 i = 0; i < ids.length; i++) {
            uint256 amount = claims[msg.sender][ids[i]];
            claims[msg.sender][ids[i]] = 0; // Clear before transfer
            token.safeTransferFrom(address(this), msg.sender, ids[i], amount, "");
        }
    }
}

3. Approval Scope: setApprovalForAll Grants ALL Token IDs

The most insidious vulnerability in ERC-1155 is setApprovalForAll(), which grants an operator permission to transfer every token ID in the contract on behalf of the approver.

function setApprovalForAll(address operator, bool approved) external {
    operatorApprovals[msg.sender][operator] = approved;
    emit ApprovalForAll(msg.sender, operator, approved);
}

function safeTransferFrom(
    address from,
    address to,
    uint256 id,
    uint256 amount,
    bytes calldata data
) external {
    require(
        from == msg.sender || operatorApprovals[from][msg.sender],
        "Not approved"
    );
    // ... transfer
}

Attack scenario:

Alice approves Bob to trade token ID 1 (worth 1000 ETH) with the intent that Bob swaps it for stablecoin. But setApprovalForAll() gives Bob permission to transfer every token ID Alice owns, including token ID 2 (a rare NFT worth millions).

Bob transfers token ID 2 to themselves and vanishes with the NFT.

Why ERC-1155 chose this design:

ERC-1155 batch operations require approval for multiple IDs at once. Implementing per-ID approval would require storing an unbounded mapping of approvals, which is prohibitively expensive in a standard that supports up to 2^256 unique token IDs.

Partial mitigations (not full fixes):

  1. UI warning: Any wallet UI that calls setApprovalForAll() should display a prominent warning that the operator gains access to all tokens, not just the ones being traded.

  2. Time-based revocation: Contracts can implement a time-decay mechanism where approvals automatically expire after N blocks:

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

function setApprovalForAll(address operator, bool approved) external {
    if (approved) {
        approvalExpiry[msg.sender][operator] = block.number + 100_000; // ~14 days
    } else {
        delete approvalExpiry[msg.sender][operator];
    }
}

function isApprovedForAll(address account, address operator) 
    public view returns (bool) 
{
    return approvalExpiry[account][operator] > block.number;
}
  1. ERC-1761 Scoped Approvals: A newer standard proposes per-ID or per-collection approval:
// Not yet widely adopted, but emerging standard
interface IERC1761 is IERC1155 {
    function approve(address spender, uint256 id, uint256 amount) external;
    function approveForAll(address spender, bool approved) external;
}

But ERC-1761 is not backward-compatible and adoption is minimal. For now, setApprovalForAll() remains an all-or-nothing permission.


4. URI Spoofing and Malicious Metadata

ERC-1155 contracts store a URI template that returns JSON metadata for each token ID:

contract MyERC1155 is ERC1155 {
    constructor() ERC1155("https://api.example.com/metadata/{id}") {}
}

If the metadata endpoint is centralized and the contract allows owner to change the URI without multi-sig or timelock, an attacker who compromises the owner key can swap metadata to serve malicious data.

// VULNERABLE: unguarded URI update
function setURI(string memory newUri) external onlyOwner {
    _setURI(newUri);
}

// Attacker compromises owner, calls:
// setURI("https://attacker.com/phishing/{id}")
//
// All marketplaces and wallets now display attacker-controlled metadata
// Attacker can claim tokens are worth 1000x actual value, driving sales

Real-world impact:

NFT collections have been compromised when owner keys were leaked. The attacker updated the metadata URI to point to phishing endpoints or changed image URLs to claim rare editions are worthless.

Fix: Immutable or time-locked metadata

// Option 1: Immutable metadata
contract ImmutableERC1155 is ERC1155 {
    string private immutable metadataURI;

    constructor(string memory _uri) ERC1155(_uri) {
        metadataURI = _uri;
    }

    // No setURI() function — metadata is permanent
}

// Option 2: Time-locked update
contract TimeLockERC1155 is ERC1155 {
    string private _newURI;
    uint256 private _uriLockTime;
    uint256 public constant URI_CHANGE_DELAY = 7 days;

    function proposeURIChange(string memory newUri) external onlyOwner {
        _newURI = newUri;
        _uriLockTime = block.timestamp + URI_CHANGE_DELAY;
    }

    function executeURIChange() external onlyOwner {
        require(block.timestamp >= _uriLockTime, "Locked");
        _setURI(_newURI);
    }
}

5. Token ID Confusion: Zero vs Non-Existent

ERC-1155 allows any ID from 0 to 2^256-1. Some contracts treat ID 0 as special (e.g., a wrapped ETH token or burn indicator), while others don't. This creates confusion and potential bugs.

// VULNERABLE: treating ID 0 as special
contract SpecialIDERC1155 is ERC1155 {
    uint256 public constant BURN_ID = 0;

    function burn(uint256 amount) external {
        _burn(msg.sender, BURN_ID, amount);
    }

    // Bug: someone mints tokens with ID 0 thinking it's a normal token
    // When they call burn(), they destroy those tokens
}

Another variant: ID confusion in upgrades

If a contract is upgraded and the new version uses a different ID for the same logical token (e.g., switching from ID 0 to ID 1 for staked ETH), old token holders lose access to their tokens unless the contract manually migrates balances.

Fix: Document and validate ID ranges

// Define valid ID ranges
uint256 public constant MIN_VALID_ID = 1;
uint256 public constant MAX_VALID_ID = type(uint256).max;

function mint(uint256 id, uint256 amount, bytes memory data) 
    external 
    onlyOwner 
{
    require(id >= MIN_VALID_ID && id <= MAX_VALID_ID, "Invalid ID");
    _mint(msg.sender, id, amount, data);
}

6. Fungible/Non-Fungible Mixed Supply Bugs

ERC-1155 allows a single contract to mint both:
- Fungible tokens: token ID 1 with supply 1,000,000 (indistinguishable copies)
- Non-fungible tokens: token ID 2 with supply 1 (unique)

But if supply tracking is incorrect, a contract can accidentally mint two "unique" NFTs with the same ID:

// VULNERABLE: supply tracking failure
contract MixedERC1155 is ERC1155 {
    mapping(uint256 => uint256) private totalSupply;

    function mint(uint256 id, uint256 amount) external {
        _mint(msg.sender, id, amount, "");
        totalSupply[id] += amount;
    }

    // Bug: if totalSupply[id] overflows or is not checked,
    // contract can mint 2 tokens with the same "unique" ID
}

Real impact:

A contract intended to mint 1 unique NFT per ID (supply = 1) mints 2 tokens with the same ID due to a bug in the minting logic. Both holders believe they own a unique 1/1 edition. When they try to sell, the marketplace detects duplicate IDs and rejects both as invalid.

Fix: Supply validation

contract SafeMixedERC1155 is ERC1155 {
    mapping(uint256 => uint256) public totalSupply;
    mapping(uint256 => uint256) public maxSupply; // Set at mint time

    function mint(uint256 id, uint256 amount) external onlyMinter {
        require(totalSupply[id] + amount <= maxSupply[id], "Exceeds max supply");
        _mint(msg.sender, id, amount, "");
        totalSupply[id] += amount;
    }

    // For NFT-only IDs:
    function mintNFT(uint256 id) external onlyMinter {
        require(totalSupply[id] == 0, "Already minted");
        _mint(msg.sender, id, 1, "");
        totalSupply[id] = 1;
    }
}

7. Missing supportsInterface Check Leads to Stuck Tokens

ERC-1155 requires recipients to implement the onERC1155Received() callback by returning a specific selector. If a contract doesn't implement this callback (or implements it incorrectly), tokens sent to it are permanently stuck.

// This contract receives an ERC-1155 but has NO callback
contract VulnerableReceiver {
    // No onERC1155Received() — transfer will revert or tokens get stuck
}

// Sender calls:
token.safeTransferFrom(sender, receiver, 1, 100, "");
// Revert: receiver doesn't implement callback
// OR (if receiver has receive() but not onERC1155Received())
// Tokens sit in contract with no way to withdraw them

Why this happens:

Wallets sometimes add a generic receive() external payable {} function to accept ETH, but forget to add onERC1155Received(). ERC-1155 tokens sent to such wallets are trapped.

Fix: Pre-flight check with supportsInterface

Before sending tokens to an unknown address, check that it implements the correct interface:

contract SafeSender {
    function safeTransferWithCheck(
        IERC1155 token,
        address to,
        uint256 id,
        uint256 amount
    ) external {
        if (to.code.length > 0) {
            require(
                IERC1155Receiver(to).supportsInterface(
                    type(IERC1155Receiver).interfaceId
                ),
                "Not ERC1155 receiver"
            );
        }
        token.safeTransferFrom(address(this), to, id, amount, "");
    }
}

Wallets and exchanges should refuse to accept ERC-1155 tokens unless the recipient implements the callback.


Detection Table: Which Tools Catch These Vulnerabilities

Vulnerability Slither Mythril AI Auditors Manual Review
onERC1155Received reentrancy Partial Partial High Required
safeBatchTransferFrom reentrancy Partial Partial High Required
setApprovalForAll scope No No Medium Required
URI spoofing No No Medium Required
Token ID confusion No No Low Required
Mixed supply bugs No No Medium Required
Missing supportsInterface No No Low Required

Notes:


Best Practices for ERC-1155 Contracts

  1. Always update state before external calls — no exceptions.
  2. Use OpenZeppelin's ERC1155 implementation — it's battle-tested and correctly ordered.
  3. Guard batch operations with ReentrancyGuard — even if state updates are ordered correctly.
  4. Document the permission model to userssetApprovalForAll() is all-or-nothing; users should revoke approval immediately after trading.
  5. Implement time-locked or immutable metadata — prevent URI spoofing.
  6. Validate token ID ranges — prevent ID confusion bugs.
  7. Pre-flight interface checks — avoid sending tokens to contracts that can't receive them.
  8. Use a multi-sig for setURI() and minting functions — limit the impact of a compromised owner key.


Audit Your ERC-1155 Contracts

ContractScan provides automatic detection of common ERC-1155 vulnerabilities, including reentrancy patterns, approval scope risks, and metadata manipulation. Upload your contract to https://contract-scanner.raccoonworld.xyz for a free security scan.

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.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →