NFT Collection Security Audit
NFT contracts appear simpler than DeFi protocols — no complex math, no liquidity pools. In practice, this is false security: NFT collections regularly lose funds due to vulnerabilities in mint logic, reentrancy in ERC-721 transfer callbacks, incorrect randomness, and royalty issues. NFT contract audit is not a formality, it is market requirement: most marketplaces and investors view an audit as baseline.
What is checked in NFT audit
ERC-721 standard and implementation
First block: ERC-721 standard compliance and correct basic implementation. OpenZeppelin or custom contract?
Custom ERC-721 implementation — immediate red flag. If team doesn't use OZ ERC721, thoroughly check:
- Correctness of
safeTransferFrom— callingonERC721Receivedon recipient contract - Correctness of
approveandsetApprovalForAlllogic - Protection from transfer to
address(0)without intent to burn - Correct
balanceOfandownerOfaccounting
Reentrancy via ERC-721 callback
This is the most common critical vulnerability in NFT. safeTransferFrom calls onERC721Received on recipient contract — this is external call in middle of transaction.
// VULNERABLE contract
contract VulnerableNFT is ERC721 {
uint256 public mintPrice = 0.1 ether;
mapping(address => uint256) public minted;
function mint(uint256 quantity) external payable {
require(msg.value >= mintPrice * quantity, "Insufficient payment");
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = ++_tokenCounter;
// safeTransferFrom → calls onERC721Received → reentrancy!
_safeMint(msg.sender, tokenId);
// State updated AFTER _safeMint — vulnerable
minted[msg.sender]++;
}
}
}
Attack: attacker creates contract with onERC721Received that calls mint again. If minted counter updated after _safeMint — can exceed mint limit per address.
// FIXED version: Checks-Effects-Interactions
function mint(uint256 quantity) external payable nonReentrant {
require(msg.value >= mintPrice * quantity, "Insufficient payment");
require(minted[msg.sender] + quantity <= MAX_PER_WALLET, "Exceeds limit");
// Effects FIRST
minted[msg.sender] += quantity;
// Interactions AFTER
for (uint256 i = 0; i < quantity; i++) {
_safeMint(msg.sender, ++_tokenCounter);
}
}
Randomness: VRF vs blockhash
On-chain randomness for reveal — frequent vulnerability. block.timestamp, blockhash, prevrandao — all manipulable.
// VULNERABLE: miner/validator can manipulate blockhash
function revealTokens() external onlyOwner {
for (uint256 i = 1; i <= totalSupply; i++) {
uint256 randomSeed = uint256(keccak256(abi.encodePacked(
blockhash(block.number - 1),
i,
block.timestamp
)));
tokenTraits[i] = _assignTraits(randomSeed);
}
}
Validator can only call revealTokens in blocks where blockhash gives favorable traits. Not theoretical risk — happened on major collections.
Correct solution: Chainlink VRF v2.
contract SecureNFT is VRFConsumerBaseV2 {
VRFCoordinatorV2Interface private vrfCoordinator;
uint64 private subscriptionId;
bytes32 private keyHash;
uint256 public randomWord; // obtained from Chainlink
bool public revealed;
function requestReveal() external onlyOwner {
require(!revealed, "Already revealed");
vrfCoordinator.requestRandomWords(
keyHash,
subscriptionId,
3, // confirmations
100000, // callbackGasLimit
1 // numWords
);
}
function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
randomWord = randomWords[0];
revealed = true;
emit Revealed(randomWord);
}
function getTraits(uint256 tokenId) public view returns (Traits memory) {
require(revealed, "Not revealed");
uint256 seed = uint256(keccak256(abi.encodePacked(randomWord, tokenId)));
return _assignTraits(seed);
}
}
Mint logic: whitelist, limits, timing
// Check in audit:
// 1. Merkle whitelist — correct double hashing
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, maxAmount))));
// NOT: keccak256(abi.encodePacked(msg.sender)) — vulnerable to hash collision
// 2. Protection from supply overage
require(totalSupply() + quantity <= MAX_SUPPLY, "Exceeds supply");
// No off-by-one errors?
// 3. Team mint doesn't exceed announced
// Verify that team allocation is really limited
// 4. Front-running protection for whitelist
// Merkle proof doesn't contain amount — can't copy proof to another address?
// Leaf must include msg.sender!
Royalty mechanism: ERC-2981
// Correct ERC-2981 implementation
contract NFTWithRoyalty is ERC721, ERC2981 {
constructor() {
// Set royalty: 5% for owner
_setDefaultRoyalty(msg.sender, 500); // 500 basis points = 5%
}
// royaltyInfo must return correct values
// Check: doesn't exceed 100%, correct receiver
function royaltyInfo(uint256, uint256 salePrice)
public view override returns (address receiver, uint256 royaltyAmount)
{
return super.royaltyInfo(0, salePrice);
}
// supportsInterface must include ERC2981
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC2981) returns (bool) {
return super.supportsInterface(interfaceId);
}
}
In audit check: royalty receiver not address(0), percentage not anomalously high, no way to change receiver without governance.
Withdraw function
Simple but critical: who can withdraw ETH from contract?
// Check in audit:
// 1. onlyOwner or multisig?
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
// 2. No pull payment vulnerabilities?
// 3. ETH can't get stuck in contract on failed transfer?
// Use call instead of transfer:
(bool success, ) = payable(owner()).call{value: address(this).balance}("");
require(success, "Transfer failed");
// 4. No way to drain before reveal/sale via exploit?
Typical findings by severity
| Severity | Example vulnerability | Frequency |
|---|---|---|
| Critical | Reentrancy in mint allows minting over supply | Rare |
| High | Manipulatable randomness for reveal | Frequent |
| High | Merkle leaf without msg.sender — proof can be stolen | Medium |
| Medium | Withdraw without nonReentrant | Frequent |
| Medium | totalSupply overflow with large mint quantity | Rare |
| Low | Missing events for critical functions | Very frequent |
| Informational | Gas optimization (ERC721A vs ERC721) | Always |
ERC-721A: audit specifics
Many collections use ERC-721A (Azuki's optimized version for batch mint). Specific checks:
- Correct
_startTokenId()— defaults to 0, but some projects want 1 -
_nextTokenId()correctly initialized - Batch transfer doesn't break ownership mapping
-
tokensOfOwnerdoesn't exceed gas limit for cold wallets with many tokens
Audit process
Static analysis. Run Slither — automatic detection of standard patterns (reentrancy, integer overflow, unprotected functions). Not replacement for manual audit, but filters basic issues.
slither contracts/NFTCollection.sol \
--solc-remaps "@openzeppelin=node_modules/@openzeppelin" \
--checklist \
--markdown-root .
Manual review. Check business logic: does code match whitepaper and announced mechanics. Automation won't understand that maxPerWallet = 100 with totalSupply = 10000 violates announced "fair launch" mechanics.
Fuzz testing. Foundry fuzzing for mint logic and edge cases:
function testFuzz_MintDoesNotExceedSupply(uint256 quantity) public {
quantity = bound(quantity, 1, 100);
vm.deal(user, 100 ether);
vm.prank(user);
nft.mint{value: 0.1 ether * quantity}(quantity);
assertLe(nft.totalSupply(), nft.MAX_SUPPLY());
}
NFT contract audit timeline: 1–2 weeks for standard collection. With custom mechanics (staking, breeding, on-chain game logic) — 2–4 weeks.







