EVM patterns
ERC721 Receiver Security: Acceptance, Custody and Callback Checks
An NFT receiver can forward its token before returning a valid acceptance selector. ERC721 receiver security therefore separates callback acceptance from current custody and the application's deposit policy.

Key facts
- Original execution
- 36 local EVM cases, 3 routes, 6 recipients, 2 policies
- Successful outcomes
- 22 succeeded, including 2 at an onward address
- Custody policy
- Both forwarding cases rejected by the final-owner check
- Pinned implementation
- OpenZeppelin Contracts 5.4.0, source integrity verified
- Boundary
- Current ownership does not prove a usable withdrawal
Trace the receiver call after ownership changes
Transfer a fixture NFT to a receiver that immediately forwards it. The receiver returns the expected acceptance selector, the safe transfer succeeds and the token finishes at the onward address. That behavior is permitted by the inspected token implementation. An application promising to hold the NFT needs an additional custody condition.
ERC721 receiver security covers the boundary where a token contract enters recipient code. In OpenZeppelin Contracts v5.4.0, safeTransferFrom first invokes transferFrom, which updates ownership. The receiver check follows. Recipient code therefore executes while it already owns the transferred token and can call a permitted transfer route.
A deployed contract recipient must implement onERC721Received and return its selector, 0x150b7a02, for this check to succeed. A different selector causes rejection. A receiver can also revert deliberately. The ERC721 specification defines this acceptance mechanism and leaves application policy to the integration.
The inspected utility checks to.code.length before making the call. Our no-code fixture address receives the token without a hook. That observation describes code present at execution; it does not establish the address's future behavior or a recovery path. Record which recipient type the application actually permits.
Plain transferFrom takes a different route. It performs the ownership transfer without this receiver acceptance call. A contract can consequently become the owner even when it has no receiving hook. Selecting a function named safe addresses a particular compatibility boundary, while the surrounding protocol still needs its own ownership and accounting rules.
What happened in 36 local EVM cases
We executed the official OpenZeppelin token source with recipients that hold, forward or reject the NFT. A second application policy checks whether the requested recipient still owns the token after the underlying call. This policy deliberately rejects forwarding for integrations that require immediate custody.
Method
- Evidence date
- Sources were retrieved and execution completed on .
- Pinned code
- The official OpenZeppelin Contracts
5.4.0package archive passed its published SHA512 integrity check. The experiment retained15required Solidity source files unchanged. - Execution
- Local Cancun EVM, Solidity
0.8.36, optimizer and viaIR enabled. The run uses fresh token and receiver fixtures for each case, with no public RPC or fork. - Complete population
- Three delivery routes, six recipient profiles and two application policies produce
36cases,12per route. Zero cases were excluded. - Routes
transferFrom,safeTransferFromand a fixturemintSafewrapper calling the library's internal_safeMint. The public mint wrappers are unrestricted test infrastructure.- Recipient profiles
- No code, deployed contract without a hook, accepting holder, accepting forwarder, wrong selector and explicit revert.
- Application policy
- Acceptance only, or a check that
ownerOfequals the requested recipient before the outer delivery frame returns. A mismatch reverts that frame. - Observations
- Ownership, surviving callback writes, callback parameters, per-token approval state and revert selector after the delivery call. Transfer fixtures begin with a token owned by the harness and an existing per-token approval.
- Reproduction
- Run
seo/research/erc721-receiver-security-2026-11-11/measurement.py --verifyagainst the retainedReceiverMatrix.t.sol, source archive andmeasurement.json. These are repository artifacts, not public download endpoints.
Results
| Route | Cases | Success | Revert | Success at requested recipient | Success at onward address |
|---|---|---|---|---|---|
| transferFrom | 12 | 12 | 0 | 12 | 0 |
| safeTransferFrom | 12 | 5 | 7 | 4 | 1 |
| Safe mint wrapper | 12 | 5 | 7 | 4 | 1 |
Across the grid, 22 calls succeeded and 14 reverted. Of the successful calls, 20 finished with the requested recipient as owner and 2 finished at the onward address. The forwarding recipient observed itself as owner inside both successful safe callbacks before sending the NFT onward.
| Route and recipient | Policy | Outcome | Final owner | Surviving hook calls |
|---|---|---|---|---|
| Safe transfer to forwarder | Acceptance only | Success | Onward address | 1 |
| Safe transfer to forwarder | Require owner after | Revert | Original owner | 0 |
| Safe mint to forwarder | Acceptance only | Success | Onward address | 1 |
| Safe mint to forwarder | Require owner after | Revert | Token absent | 0 |
| Safe transfer to wrong selector | Acceptance only | Revert | Original owner | 0 |
| Plain transfer to contract without hook | Require owner after | Success | Requested recipient | 0 |
The final row shows why a custody equality check has limited scope. Ownership at the requested contract can be correct even though no receipt callback ran and that fixture supplies no withdrawal function. Conversely, the failed safe-mint row has no token after rollback. Its zero callback counter does not establish that recipient code was never entered.
All successful transfers cleared the fixture's per-token approval. Rejected transfer deliveries restored the previous owner and its nonzero approval. We independently checked every row against its route, recipient profile and policy, then replayed the raw observations exactly. The two policy rejections identify a custody requirement, not a new token-library vulnerability.
Limits
- The grid exercises one ordinary token identifier and one onward transfer. It does not measure exploit prevalence, losses, gas or deployed applications.
- All calls originate from the owner harness. Separate approved-operator routes, withdrawals, burns and custom token overrides were not executed.
- Recipient counters are read after the delivery frame. Writes inside a rejected callback roll back with that frame.
- Constructor recipients, short malformed ABI responses and recursive application minting are source-review or proposed-test topics outside these
36cases. - A successful post-call ownership check is a point-in-time condition. It does not prove permanent custody, correct beneficiary credit or a usable exit.
Authenticate the collection before crediting a deposit
A receiving application should first establish which collection called it. The hook's msg.sender identifies the calling token contract. Its operator argument identifies the transfer initiator, while from identifies the previous owner. Those fields answer different questions and should not share one authorization rule.
| Input or observation | Meaning | Application check |
|---|---|---|
| msg.sender | Calling collection contract | Match an approved collection and intended network |
| operator | Address initiating this transfer | Apply the operation's initiator rules without assuming token ownership |
| from | Previous owner, or zero for the tested mint route | Reconcile the depositor or mint policy |
| Collection and tokenId | Asset identity within the network | Use the full identity for ledger keys and duplicate-credit controls |
| data | Caller-supplied extra payload | Validate the application's intended beneficiary and operation |
A public receiver function can also be called directly by an unrelated contract. A caller supplying plausible arguments has not thereby deposited an approved collection. Test that a direct forged callback cannot create a credit. An arbitrary token can also report misleading ownership, so calling its ownerOf before establishing collection trust does not solve that boundary.
A rejection rule in the hook controls the routes that invoke that hook. The plain-transfer rows demonstrate a route that can still assign ownership without recipient acceptance. A collection allowlist therefore needs a ledger policy for unexpected assets as well as a callback check. An unsolicited NFT arriving at the address must not automatically create a liability. If recovery is supported, its authorization must distinguish an accidental asset from one already backing a credited deposit.
Use network, collection address and token identifier to name the asset. A second collection can issue the same identifier without representing the same NFT. For a custody ledger, record which accepted deposit creates which liability, and make repeated or unsolicited callbacks an explicit policy case. The token standard does not select the application's beneficiary.
Operator support needs deliberate treatment. A legitimate transfer can be initiated through an approval rather than directly by its owner. A rule requiring every operator to equal from can reject a supported workflow. Decide which routes the product accepts, then verify both the intended positive route and an unauthorized alternative. Our fixture records operator and previous owner but does not execute that separate operator matrix.
Deposit credit should follow the application's own evidence requirement. An escrow might require current ownership at its address and a matched deposit intent. A forwarding integration may instead complete a different accounting transition. Document that transition so a callback cannot be counted once as receipt and again as an unrelated claim.
Separate safe minting from application reentrancy controls
The library's _safeMint updates token state through _mint before checking recipient acceptance. The hook receives a zero previous-owner address. The accepting forwarder can therefore transfer its newly minted token during the callback, just as it can after a safe transfer. Our successful mint row demonstrates that sequence.
Inspect each mint entrypoint rather than assuming they all reach the acceptance helper. The base implementation exposes internal building blocks, and a customized public mint path chooses which ones it uses. The plain mint wrapper in our harness only prepares transfer fixtures; it is not a separate measured recipient route. A production review should also identify alternative mint paths and any override that changes when external code can run.
A minting application's sale or claim state is another layer. If a recipient can reenter the application while its quota, payment or claim state still reflects the earlier operation, the application needs an invariant for that boundary. Inspect state reservation before the first external call and any other entrypoint that can consume the same entitlement.
For a batch mint, repeat that review at each callback inside the loop. Updating a total only after the loop can leave an intermediate state that differs from the final intended allocation. This is a proposed review question, not a batch-mint result from our experiment. The fixture contains no sale counter, price or public claim ledger.
OWASP documents NFT safe-transfer callback reentrancy and illustrates a reentrancy guard as a mitigation. Match the guard and state-update order to all relevant routes. A guard on one entrypoint does not define the receiver's collection policy, and collection authentication does not complete the caller's reentrancy review. Use the OWASP callback weakness reference together with the broader reentrancy test matrix.
The observed onward transfer is an authorized use of token ownership. Whether a callback violates a protocol depends on the surrounding protocol's promised state transition. Do not turn an expected composability feature into an exploit finding without showing which requirement failed.
Verify custody and a usable withdrawal route
OpenZeppelin's ERC721Holder is an abstract acceptance helper. Its hook returns the selector for all incoming transfers. A concrete application inheriting it still needs policies for accepted collections and for using or releasing the NFTs it receives. The OpenZeppelin ERC721 API explicitly warns that the contract must be able to use its token.
For an escrow that promises immediate retention, check ownership after the complete interaction rather than relying on the callback result. In our experiment, an outer custody assertion reverts both forwarding cases. In a design that intentionally forwards to a strategy or wrapper, the required final owner may instead be that approved destination. A blanket self-custody assertion would obstruct that design.
- Trace when the token's ownership update becomes visible to recipient code.
- Allow or reject forwarding according to the integration's documented destination policy.
- Read the required final owner after the interaction, then reconcile the deposit ledger.
- Exercise the authorized exit and its unauthorized control separately. Holding an NFT does not demonstrate that the contract can release it.
The experiment's final-owner check executes inside the same outer delivery frame and reverts on a mismatch. That placement is what lets it undo the forwarding state change in those rows. An offchain observer noticing the wrong owner after a completed transaction cannot obtain that rollback by changing its own display or marking the operation failed. Specify both the observation point and the response to a violated condition when implementing the guard.
Review permission state alongside custody. The inspected transfer update clears per-token approval, which the successful transfer rows confirm. Owner-wide operator grants have a different scope and need their own review. An exit function that uses an operator or an approved account must make the intended authority explicit.
The withdrawal test should cover the asset actually held, the caller permitted to release it and the destination selected by that caller. Include a negative authorization control. These are proposed application checks; our empty no-hook recipient intentionally supplies no release function and the grid does not execute withdrawals.
Turn receiver behavior into release checks
Start with the smallest observable property for the route being reviewed. Assert acceptance, ownership and application credit separately so a passing call does not conceal which condition was actually tested. Keep the deployed token revision and receiver implementation with the result.
| Control | Expected evidence | Boundary it tests |
|---|---|---|
| Wrong collection or direct forged callback | No deposit credit | Token identity and ledger authorization |
| Approved operator and owner routes | Only supported initiators accepted | Operator policy independent of previous owner |
| Forwarding or custom token override | Required final owner and liability agree | Application custody condition |
| Recursive mint or claim attempt | Entitlement and supply invariants hold | External-call intermediate state |
| Authorized exit and unauthorized control | Release succeeds only for the intended caller | Usable custody and withdrawal authority |
Diagnose an invalid-receiver failure at the actual recipient and implementation. The inspected utility converts an empty callback failure or a wrong selector into ERC721InvalidReceiver, while bubbling nonempty revert data. A customized token or receiver can introduce additional failure paths. Preserve the actual call result instead of attributing every rejection to a missing interface.
ERC-1155 uses different receipt hooks and represents quantities, including batch operations. A receiver written for that standard does not automatically supply the ERC-721 hook. Keep the interfaces and accounting models separate, using the existing ERC1155 receiver security guide for that integration. Interface discovery is another supporting check described in ERC165 interface detection.
Custom ownership overrides, wrappers and upgradeable recipients require evidence from their actual code. The pinned base-library experiment cannot establish what a later implementation returns from ownerOf or which routes it exposes. Carry the concrete custody condition into a protocol invariant and rerun the relevant controls when the implementation changes.
Describe those routes in the audit scope and compare evidence through the audit analysis hub and a documented provider profile such as Pharos Production. Retain the final owner, approval state and credited liability beside the callback outcome. Each field supports a different acceptance decision.
Frequently asked questions
Does an ERC721 recipient need to implement ERC165?
The inspected acceptance utility calls the receiver hook directly and does not query supportsInterface. Our accepting fixture implements IERC721Receiver without ERC165. The token standard has separate discovery requirements, so distinguish the collection interface from the recipient hook.
Must ERC721 token identifiers be sequential or start at one?
No. ERC721 says identifiers need not follow a particular pattern and must be treated as a black box. Do not infer an asset by a sequential index. Our tokenId 1 is a fixed test input, not a standard requirement.
Does ERC721 prescribe an encoding for the extra data bytes?
No application-level format is prescribed for the extra data. Define and validate the format used by your receiver, including what an empty payload means. A decoded beneficiary field still needs the authorization required by the deposit operation.
Can a contract under construction miss the receiver callback?
Yes, the inspected utility skips acceptance when the recipient has no runtime code. Solidity documents that final code is stored after constructor execution, so construction is a relevant code-presence boundary. This is a source-derived limitation, not an additional executed case in our 36-row grid.