ERC-721 (NFT) Overview: Implementation, Security, and Best Practices
This article presents a global overview of ERC-721 standard (NFT), covering its core functions, metadata, security best practices and how to use it with OpenZeppelin.
If you want to know more about the different standards and extensions to represent NFT, you can read my article NFT Standards on Ethereum - ERC-721 and Beyond.
[TOC]
Standard Overview
Every ERC-721 compliant contract must implement the ERC721 and ERC165 interfaces:
A quick summary from the EIP-721
| Function/Event | Description | Return type | Remarks / Security Considerations |
|---|---|---|---|
Transfer (event) |
Emitted when an NFT is transferred, created (from == 0), or burned (to == 0). |
- | During contract creation, any number of NFTs may be created and assigned without emitting Transfer.<br / |
Approval (event) |
Emitted when an NFT’s approved address is changed or reaffirmed. | - | The zero address indicates there is no approved address. When a Transfer event emits, this also indicates that the approved address for that NFT (if any) is reset to none. |
ApprovalForAll (event) |
Emitted when an operator is enabled or disabled to manage all NFTs of an owner. | - | - |
balanceOf(address _owner) |
Returns the number of NFTs owned by an address. Ensure _owner is a valid address; revert if it’s zero. |
uint256 | It will revert if there is no owner ! |
ownerOf(uint256 _tokenId) |
Returns the owner of a specific NFT. Ensure _tokenId exists; revert if it’s invalid. |
address | It will revert if there is no owner ! |
safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) |
Transfers an NFT, ensuring _to can handle ERC-721 tokens (via onERC721Received). |
- | Use safeTransferFrom instead of transferFrom to prevent tokens from being locked in contracts that don’t implement onERC721Received.Warning: adds a reentrancy-risk |
safeTransferFrom(address _from, address _to, uint256 _tokenId) |
Same as above but without the extra data parameter. |
- | Preferred over transferFrom to prevent lost tokens in contracts. |
transferFrom(address _from, address _to, uint256 _tokenId) |
Transfers NFT ownership without checking if _to can handle it. |
- | Risk of lost tokens! Ensure _to is an EOA (Externally Owned Account) or a contract that supports ERC-721. |
approve(address _approved, uint256 _tokenId) |
Approves another address to transfer a specific NFT. | - | – |
setApprovalForAll(address _operator, bool _approved) |
Grants/revokes permission for an operator to manage all NFTs of the caller. | - | Be careful when approving third-party operators (e.g., marketplaces) to prevent mass asset loss. |
getApproved(uint256 _tokenId) |
Returns the approved address for a specific NFT. | address | Ensure _tokenId exists to avoid reverting transactions. |
isApprovedForAll(address _owner, address _operator) |
Returns whether an operator is approved for all NFTs of an owner. | bool | Regularly check operator approvals to avoid unauthorized transfers. |
supportsInterface(bytes4 interfaceID) (ERC165) |
Checks if a contract implements a specific interface (e.g., ERC-721). | bool | Required for ERC-721 compliance. Ensures compatibility with other smart contracts. |
ERC-721 Token Receiver Interface (ERC721TokenReceiver)
A wallet/broker/auction application MUST implement the wallet interface if it will accept safe transfers.
The ERC721TokenReceiver interface is crucial for smart contracts that intend to receive ERC-721 tokens securely. It ensures that the recipient contract can handle NFTs properly and prevents tokens from getting stuck in incompatible contracts.
Function Reference Table
| Function | Description | Return type | Technical Details & Security Considerations |
|---|---|---|---|
onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) → bytes4 |
Handles the receipt of an NFT when safeTransferFrom() is used. Must return a specific magic value to confirm acceptance. |
bytes4 | - MUST return 0x150b7a02 (bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))) or the transaction will revert. - Called by the NFT contract on _to (receiver contract).- The _operator is the sender (could be an approved operator). - If the recipient contract does not implement this function or returns an incorrect value, the transfer fails. |
Metadata
The metadata extension is OPTIONAL for ERC-721 smart contracts-
This allows your smart contract to be interrogated for its name and for details about the assets which your NFTs represent.
ERC-721 Metadata Extension Function Reference Table
| Function | Description | Return type | Remarks / Security Considerations |
|---|---|---|---|
name() |
Returns the descriptive name of the NFT collection. | string | The name should be immutable or only modifiable by the contract owner to prevent misleading changes. It should ideally not be modified to maintain consistency. |
symbol() |
Returns the symbol (ticker) of the NFT collection. | string | Similar to name(), it should be set at deployment or only modifiable by the contract owner to prevent misleading changes. It should ideally not be modified to maintain consistency. |
tokenURI(uint256 _tokenId) |
Returns a unique URI for a given NFT, typically pointing to metadata (e.g., JSON file with image, description, attributes). URIs are defined in RFC 3986. Revert if _tokenId is not a valid NFT.The URI may point to a JSON file that conforms to the “ERC721 Metadata JSON Schema”. |
string | Ensure _tokenId exists before returning a URI to prevent invalid lookups, otherwise revertUse IPFS or Arweave instead of centralized servers to ensure metadata immutability and prevent broken links. See my article on Arweave |
Build NFT contract
Minimum function
If you use OpenZeppelin as the core library, the majority of ERC-721 functions are already implemented. There are nevertheless several functions, not directly part of ERC-721 standard, which could be relevant to add depending of your use case:
Mint, eventuallyburn- Access control or ownership system to protect your sensitive functions (e.g mint/burn)
setBaseUri: to set the base URI, common for all tokens URIbaseURI(): getter to return Base URI for computing {tokenURI}. If set, the resulting URI for each token will be the concatenation of thebaseURIand thetokenId. OpenZeppelin only provides the internal function_baseURI()which can not be called outside of the NFT contract.
Mint
With OpenZeppelin, the internal function _mint is not recommended and it is preferable to use ` _safeMint` instead.
_safeMintwill check if the recipient of the newly created NFT is an EOA or a smart contract.
If it is a contract, the function will check that the contract implements the interface IERC721Receiver which indicates that this contract can manage ERC-721 token.
See docs.openzeppelin.com/contracts/5.x/api/token/erc721#ERC721-_mint-address-uint256-
Security
Transfer
safeTransferFrom
If you build a contract to manage and transfer NFT, e.g an NFT marketplace:
- If the contract calls .(safe)transferFrom, then in the majority of cases from parameter must be msg.sender. Otherwise an attacker can take advantage of other user’s appovals and steal their NFT from them! Same principle with a call using .transferFrom
- Warning: the OpenZeppelin implementation of ERC721 and ERC1155 vulnerable to reentrancy attacks, since safeTransferFrom functions perform an external call to the user address (onReceived)!
Main reference: Pessimistic - Auditing Tips for NFT Projects
Transfer and TransferFrom
OpenZeppelin recommand to use safeTransferFrominstead even if it adds a reentrancy risk
Note that the caller is responsible to confirm that the recipient is capable of receiving ERC-721 or else they may be permanently lost. Usage of
safeTransferFromprevents loss, though the caller must understand this adds an external call which potentially creates a reentrancy vulnerability
See docs.openzeppelin.com/contracts/5.x/api/token/erc721#IERC721-transferFrom-address-address-uint256-
And Quillhash - NFT-Attack-Vectors/blob/main/data/16.md
Approval/allowance
For ERC-20 token, if a token holder wants to update the amount approved to a specific spender, there is the possibility for this spender to front-run the approval by spending the approval before the new approval is set, see ERC20 API: An Attack Vector on the Approve/TransferFrom Methods-
ERC-721 is less concerned by this problem because each NFT is unique, the approval quantity is none or one. However, if a token holder wants to revoke the approval given to a spender, there is however always the possibility for this spender to spend the approval before it is approved on the blockchain (front-running).
ERC721 detector
Aderyn - IncorrectERC721InterfaceDetector
Aderyn has a detector to check if the ERC-721 interface is correctly implemented or not.
https://github.com/Cyfrin/aderyn/blob/aderyn-v0.5.8/aderyn_core/src/detect/high/incorrect_erc721_interface.rs
The detector will notably check the following thing:
- The return type for each function
- If the signature match the interface
Here a summary tab for the return value:
| ERC-721 Function | Expected Return Type | Problem if not |
|---|---|---|
balanceOf(address owner) |
uint256 |
If it doesn’t return uint256, it’s captured. |
getApproved(uint256 tokenId) |
address |
If it doesn’t return address, it’s captured. |
ownerOf(uint256 tokenId) |
address |
If it doesn’t return address, it’s captured. |
safeTransferFrom(address from, address to, uint256 tokenId) |
no return (void) | If it returns anything, it’s captured. |
transferFrom(address from, address to, uint256 tokenId) |
no return (void) | If it returns anything, it’s captured. |
approve(address to, uint256 tokenId) |
no return (void) | If it returns anything, it’s captured. |
setApprovalForAll(address operator, bool approved) |
no return (void) | If it returns anything, it’s captured. |
isApprovedForAll(address owner, address operator) |
bool |
If it doesn’t return bool, it’s captured. |
Reference: summary tab made with the help of ChatGPT
Example
This example is a very simple basic NFT contract with only a mint function, the possibility to put in pause the contract and an access control with an ownership system.
To define the tokenId of an NFT, the contract use a counter (tokenId) which is incremented afer the mint.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
contract MyNFT is ERC721, Ownable, Pausable {
uint256 tokenId;
function mint(address to) public onlyOwner whenNotPaused {
_mint(to, nextTokenId);
nextTokenId++;
}
function pause() public onlyOwner {
_pause();
}
function unpause() public onlyOwner {
_unpause();
}
}
Mindmap
Created with the help of ChatGPT

Error management (ERC-6093)
OpenZeppelin implements for their ERC-721 implementation since the version 5.0.0 the standard eip-6093 created by their team.
This EIP defines a standard set of custom errors for commonly-used tokens, notably ERC-721 tokens.
Summary tab
| Error | Description | Recommended | Must / MUST NOT | Usage Guidelines |
|---|---|---|---|---|
| ERC721InvalidOwner(address owner) | Address can’t be an owner (e.g., address(0)). Used in balance queries. |
Use for addresses disallowed as owners (e.g., address(0)). |
MUST NOT be used for transfers. | Use ERC721IncorrectOwner instead. |
| ERC721NonexistentToken(uint256 tokenId) | The tokenId does not exist (not minted or burned). | N/A | MUST be used only for non-minted or burned tokens. | Helps prevent interactions with invalid tokens. |
| ERC721IncorrectOwner(address sender, uint256 tokenId, address owner) | Error related to token ownership. Used in transfers. | N/A | sender MUST NOT be the current owner. MUST NOT be used for approval operations. |
Used to enforce correct ownership during transfers. |
| ERC721InvalidSender(address sender) | Failure related to the token sender. Used in transfers. | Use for disallowed transfers from address(0). |
MUST NOT be used for approval operations. MUST NOT be used for ownership or approval checks. | Use ERC721IncorrectOwner or ERC721InsufficientApproval instead. |
| ERC721InvalidReceiver(address receiver) | Failure related to the token receiver. Used in transfers. | Use for disallowed transfers to address(0). Use for transfers to non-ERC721Receiver contracts. |
MUST NOT be used for approval operations. | Helps prevent lost tokens by rejecting invalid receivers. |
| ERC721InsufficientApproval(address operator, uint256 tokenId) | Operator lacks approval for the transfer. | N/A | isApprovedForAll(owner, operator) MUST be false. getApproved(tokenId) MUST NOT be operator. |
Ensures transfers only occur with correct approvals. |
| ERC721InvalidApprover(address approver) | Failure related to the approver (token owner granting approval). Used in approvals. | Use for disallowed approvals from address(0). |
MUST NOT be used for transfers. | Prevents invalid approval attempts. |
| ERC721InvalidOperator(address operator) | Failure related to the operator being approved. Used in approvals. | Use for disallowed approvals to address(0). |
operator MUST NOT be the token owner. MUST NOT be used for transfers. |
Use ERC721InsufficientApproval instead. |
Mindmap
Created with the help of ChatGPT

References
- ChatGPT with several different inputs: “Create me an article to explain the base to create a NFT contract with Openzeppelin. Add security tips inside” , “Summarize the ERC-6093 and ERC-721 in a tab, keep the technical point”
- Pessimistic - Auditing Tips for NFT Projects
- Quillhash - NFT-Attack-Vectors/blob/main/data/16.md