NFT approvals are a billion-dollar attack surface. Between 2021 and 2025, phishing campaigns targeting ERC-721 approval flows drained hundreds of millions in NFT assets from collectors and traders. The mechanics are deceptively simple: a single on-chain approval can hand a malicious actor full control over an entire collection sitting in a user's wallet.
Yet the problems go beyond phishing. ERC-721's approval model has six distinct vulnerability classes that affect smart contract developers, marketplace operators, and NFT protocol designers — not just end users tricked by fake minting sites. Bugs in custom transfer implementations, off-chain listing systems, royalty enforcement, and shared operator patterns have all led to significant losses.
This post walks through each vulnerability class with vulnerable Solidity code, attack mechanics, and the fix. If you are auditing an NFT contract or building a marketplace, these are the patterns to look for.
1. setApprovalForAll Phishing
setApprovalForAll(operator, true) grants an operator permission to transfer every token in a collection owned by the caller. There is no per-token limit, no expiry, and no restriction on how many tokens the operator can move.
This makes it the perfect phishing target.
// VULNERABLE: user is tricked into signing this transaction
// The interface shows "Approve" -- it looks like a single-token approve
IERC721(nftContract).setApprovalForAll(attackerAddress, true);
When a user calls setApprovalForAll(attacker, true), the attacker can immediately drain every token from the victim's wallet across the entire collection:
// Attacker drains all tokens after phishing victim
contract NFTDrainer {
function drainAll(
address nftContract,
address victim,
uint256[] calldata tokenIds
) external {
IERC721 nft = IERC721(nftContract);
for (uint256 i = 0; i < tokenIds.length; i++) {
nft.transferFrom(victim, msg.sender, tokenIds[i]);
}
}
}
The attack vector is especially dangerous in wallet UIs that display signature requests without clearly distinguishing between approve(tokenId) (single token) and setApprovalForAll (entire collection). Fake mint sites exploit this by prompting users to "approve the contract" as a setup step, when they are actually signing a collection-wide approval.
Fix: Prefer per-token approval in protocol design
// SECURE: use tokenId-scoped approval where possible
// Only grant setApprovalForAll to well-audited, trusted marketplace contracts
function listToken(uint256 tokenId, address marketplace) external {
require(trustedMarketplaces[marketplace], "Untrusted marketplace");
// Approve only the specific token, not the entire collection
nft.approve(marketplace, tokenId);
}
Protocols should use approve(tokenId) for single listings and reserve setApprovalForAll only for audited, immutable marketplace contracts. Users should be educated that any site asking for setApprovalForAll is granting that contract full control over every NFT they own in that collection.
2. Approval Not Cleared on Transfer
The ERC-721 standard (EIP-721) explicitly requires that _transfer MUST clear the approved address for the transferred token:
"When a Transfer event is emitted, this also indicates that the approved address for that NFT (if any) is reset to none."
Custom transfer implementations frequently miss this requirement.
// VULNERABLE: custom transfer that forgets to clear approval
contract BrokenNFT is ERC721 {
function _transfer(address from, address to, uint256 tokenId) internal override {
require(ownerOf(tokenId) == from, "Not owner");
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
// BUG: _tokenApprovals[tokenId] is never cleared
emit Transfer(from, to, tokenId);
}
}
The attack scenario: Alice owns token 42 and approves Bob to transfer it. Alice then sells token 42 directly to Carol via the contract's built-in transfer. Carol now owns token 42 — but Bob's approval is still stored in _tokenApprovals[42]. Bob can call transferFrom(carol, bob, 42) and steal the token from Carol at any time.
Fix: Always clear approvals in _transfer
// SECURE: clear approval as part of every transfer
function _transfer(address from, address to, uint256 tokenId) internal override {
require(ownerOf(tokenId) == from, "Not owner");
// Clear approval BEFORE updating ownership
delete _tokenApprovals[tokenId];
emit Approval(ownerOf(tokenId), address(0), tokenId);
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
OpenZeppelin's ERC721 implementation handles this correctly. Any project that overrides _transfer without calling super._transfer is responsible for clearing approvals itself. This is a frequent bug in contracts that add custom logic — royalty tracking, staking hooks, soulbound checks — without preserving the base class invariants.
3. Marketplace Order Replay After Cancel
Most NFT marketplaces rely on off-chain order books: sellers sign typed data (EIP-712) that encodes listing terms. This signature is stored off-chain and submitted on-chain only when a buyer wants to fill the order. Cancellation is the weak point.
// VULNERABLE: cancellation only marks a flag on-chain
// but the original signature is still mathematically valid
mapping(bytes32 => bool) public cancelledOrders;
function cancelOrder(bytes32 orderHash) external {
cancelledOrders[orderHash] = true;
}
function fillOrder(Order calldata order, bytes calldata signature) external {
bytes32 orderHash = _hashOrder(order);
require(!cancelledOrders[orderHash], "Order cancelled");
require(_verifySignature(order.seller, orderHash, signature), "Bad sig");
// Execute trade...
}
The problem: the original signed message is stored in the marketplace's off-chain order book, and anyone who saved it can attempt to replay it later. A seller who cancels a low-price listing and relists at a higher price is vulnerable to an attacker who kept the original signature and replays it the moment the on-chain cancel transaction lands — or even sandwiches the cancel.
Fix: Nonce-based cancellation
// SECURE: each order includes a seller nonce;
// incrementing the nonce invalidates all previous signatures atomically
mapping(address => uint256) public nonces;
struct Order {
address seller;
uint256 tokenId;
uint256 price;
uint256 nonce; // must match seller's current nonce
uint256 expiry;
}
function cancelAllOrders() external {
nonces[msg.sender]++; // invalidates every prior signature in one tx
}
function fillOrder(Order calldata order, bytes calldata signature) external {
require(block.timestamp < order.expiry, "Expired");
require(order.nonce == nonces[order.seller], "Invalid nonce");
bytes32 orderHash = _hashOrder(order);
require(_verifySignature(order.seller, orderHash, signature), "Bad sig");
// Execute trade...
}
Including a per-seller nonce in the signed message means that cancelling all orders requires a single on-chain transaction that increments the nonce. Every previously signed message is immediately invalid because the nonce no longer matches. Seaport uses a combination of nonces and per-order counters for exactly this reason.
4. Royalty Bypass via Direct Transfer
ERC-2981 defines a standard interface for NFT royalty information, but it does not enforce payment. Any marketplace that chooses to ignore royaltyInfo() can legally call transferFrom and pay nothing. Direct peer-to-peer transfers bypass royalties entirely.
// VULNERABLE: ERC-2981 royalties are advisory only
// Any caller can bypass them with a direct transferFrom
function transferFrom(address from, address to, uint256 tokenId) public override {
// No royalty enforcement here — ERC-2981 only provides lookup
super.transferFrom(from, to, tokenId);
}
// This call skips royalties completely:
// nft.transferFrom(seller, buyer, tokenId);
The creator receives zero royalties on any sale that routes through a non-compliant marketplace or a direct wallet-to-wallet transfer, even if royaltyInfo is properly implemented.
Fix: On-chain royalty enforcement via operator filtering
Manifold's Creator Royalty enforcement pattern maintains a registry of non-compliant marketplace addresses and blocks them from acting as operators:
// SECURE: enforce royalties by blocking non-compliant operators
import {OperatorFilterer} from "operator-filter-registry/src/OperatorFilterer.sol";
contract RoyaltyEnforcedNFT is ERC721, ERC2981, OperatorFilterer {
constructor() OperatorFilterer(CANONICAL_CORI_SUBSCRIPTION, true) {}
// transferFrom and safeTransferFrom check the operator filter
function transferFrom(
address from,
address to,
uint256 tokenId
) public override onlyAllowedOperator(from) {
super.transferFrom(from, to, tokenId);
}
function setApprovalForAll(
address operator,
bool approved
) public override onlyAllowedOperatorApproval(operator) {
super.setApprovalForAll(operator, approved);
}
}
The operator filter registry blocks approvals to and transfers through marketplace contracts that do not honor ERC-2981 royalties. This is a contentious design — it restricts token transferability — but it is the only on-chain mechanism that provides creator royalty guarantees. Projects should document this trade-off clearly.
5. Cross-Collection Approval Attack (Shared Operator)
When users grant setApprovalForAll to an operator contract, that approval applies to the specific collection. But many users interact with the same operator contract across multiple collections — and a compromise of the shared operator drains everything.
// VULNERABLE: a single malicious operator approved across two collections
// User approves sharedMarketplace for collection A
nftA.setApprovalForAll(sharedMarketplace, true);
// User later approves sharedMarketplace for collection B
nftB.setApprovalForAll(sharedMarketplace, true);
// If sharedMarketplace is compromised or malicious:
contract CompromisedMarketplace {
function drainBothCollections(
address victim,
IERC721 nftA,
IERC721 nftB,
uint256[] calldata idsA,
uint256[] calldata idsB
) external {
for (uint256 i = 0; i < idsA.length; i++) {
nftA.transferFrom(victim, msg.sender, idsA[i]);
}
for (uint256 i = 0; i < idsB.length; i++) {
nftB.transferFrom(victim, msg.sender, idsB[i]);
}
}
}
This attack pattern becomes critical when:
- A marketplace contract has an upgrade mechanism that can change logic
- The operator contract has a delegatecall vector
- An operator contract's owner key is compromised
Fix: Scope operators per collection and revoke unused approvals
// SECURE: use collection-scoped, immutable operator contracts
// and provide a clean revocation flow
contract ScopedMarketplace {
// Each collection gets its own isolated operator instance
mapping(address => address) public collectionOperators;
function createCollectionOperator(address nftContract) external returns (address) {
CollectionOperator op = new CollectionOperator(nftContract);
collectionOperators[nftContract] = address(op);
return address(op);
}
}
// Provide users a single-transaction revoke-all function
function revokeAllApprovals(address[] calldata nftContracts, address operator) external {
for (uint256 i = 0; i < nftContracts.length; i++) {
IERC721(nftContracts[i]).setApprovalForAll(operator, false);
}
}
Protocol designers should never reuse a single upgradeable operator contract across multiple high-value collections without extensive security controls. A security failure in one integration becomes a blast radius that encompasses every collection the operator touches.
6. isApprovedForAll Griefing (Approval Race Condition)
When a contract or user needs to switch from operator A to operator B, the naive implementation involves two transactions: revoke A, then approve B. This creates a window where neither operator is approved — and that window can be exploited.
// VULNERABLE: two-step operator rotation creates a gap
// Transaction 1 (block N):
nft.setApprovalForAll(operatorA, false);
// ---- WINDOW: no operator approved ----
// Attacker monitors mempool, sees tx1 land, sandwiches tx2
// Transaction 2 (block N or N+1):
nft.setApprovalForAll(operatorB, true);
A contract that checks isApprovedForAll between these two calls will find no approved operator and revert. In automated protocols where approvals are required to continue an in-flight operation (such as a lending protocol checking collateral ownership), this gap can cause transactions to fail at critical moments — preventing liquidations, loan repayments, or escrow releases.
In more aggressive scenarios, a griefing attacker can frontrun the second setApprovalForAll call, causing a time-sensitive operation to fail in a way that benefits the attacker financially.
Fix: Atomic operator swap
// SECURE: swap operators atomically in a single transaction
function swapOperator(
IERC721 nft,
address oldOperator,
address newOperator
) external {
// Approve new operator BEFORE revoking old one
// No window exists where neither operator is approved
nft.setApprovalForAll(newOperator, true);
nft.setApprovalForAll(oldOperator, false);
}
Approving the new operator before revoking the old one eliminates the gap entirely. There is a brief moment where both operators are approved, but this is far safer than the alternative. For protocols that manage operator state on behalf of users, this should be exposed as a single atomic function rather than two separate calls.
What ContractScan Detects
ContractScan analyzes ERC-721 contracts for all six vulnerability classes automatically. Upload your contract and receive a full report in seconds.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| setApprovalForAll phishing surface | Flags contracts that call setApprovalForAll on user behalf without allowlist checks |
High |
| Approval not cleared on transfer | Static analysis of _transfer overrides for missing delete _tokenApprovals[tokenId] |
Critical |
| Marketplace order replay | Detects order structs lacking nonce fields in EIP-712 typed data | High |
| Royalty bypass via direct transfer | Checks for ERC-2981 implementation without operator filter enforcement | Medium |
| Cross-collection shared operator | Identifies upgradeable operator contracts approved across multiple collections | High |
| Approval race condition | Flags two-transaction operator rotation patterns in protocol code | Medium |
Summary
ERC-721's approval model is minimal by design, and that minimalism creates attack surface at every layer of the NFT stack. The six vulnerability classes covered here — phishing via setApprovalForAll, stale approvals after transfer, signature replay on order cancellation, royalty bypass, shared operator blast radius, and approval race conditions — span everything from end-user social engineering to subtle protocol bugs.
The common thread is that approvals are powerful and long-lived. Unlike token transfers, approvals persist indefinitely and apply to all present and future holdings in a collection. Any protocol that touches the approval system should treat it with the same caution as a private key.
Scan your ERC-721 contracts at contractscan.io before deployment.
Related Posts
- NFT Randomness Security: Chainlink VRF and On-Chain Entropy Risks
- NFT Mint Mechanics: Dutch Auction, Allowlist, and Mint Security Vulnerabilities
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.