DeFi Security AllianceRequest an audit
Menu

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.

A metal collectible tile passing through a blue receiving gate beside an onward track and inspection frame, illustrating NFT custody checks.

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.0 package archive passed its published SHA512 integrity check. The experiment retained 15 required 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 36 cases, 12 per route. Zero cases were excluded.
Routes
transferFrom, safeTransferFrom and a fixture mintSafe wrapper 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 ownerOf equals 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 --verify against the retained ReceiverMatrix.t.sol, source archive and measurement.json. These are repository artifacts, not public download endpoints.

Results

Complete local EVM grid. Final ownership is observed after the outer delivery call.
RouteCasesSuccessRevertSuccess at requested recipientSuccess at onward address
transferFrom12120120
safeTransferFrom125741
Safe mint wrapper125741

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.

Selected rows showing receipt, forwarding and rollback. Callback counts include only writes surviving the delivery frame.
Route and recipientPolicyOutcomeFinal ownerSurviving hook calls
Safe transfer to forwarderAcceptance onlySuccessOnward address1
Safe transfer to forwarderRequire owner afterRevertOriginal owner0
Safe mint to forwarderAcceptance onlySuccessOnward address1
Safe mint to forwarderRequire owner afterRevertToken absent0
Safe transfer to wrong selectorAcceptance onlyRevertOriginal owner0
Plain transfer to contract without hookRequire owner afterSuccessRequested recipient0

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 36 cases.
  • 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.

Receiver inputs and the application decision each can support
Input or observationMeaningApplication check
msg.senderCalling collection contractMatch an approved collection and intended network
operatorAddress initiating this transferApply the operation's initiator rules without assuming token ownership
fromPrevious owner, or zero for the tested mint routeReconcile the depositor or mint policy
Collection and tokenIdAsset identity within the networkUse the full identity for ledger keys and duplicate-credit controls
dataCaller-supplied extra payloadValidate 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.

Three boundaries in a received NFT A token updates ownership before entering receiver code. The receiver may forward the NFT. After the callback, the application checks its required final owner and separately verifies that an authorized exit exists. Token ownership updateRecipient becomes owner Receiver code executesForwarding may occur Check required final ownerApply the deposit policy Separate release check: an authorized withdrawal must work
A valid acceptance response and a correct ownership observation answer different parts of a custody review.
  1. Trace when the token's ownership update becomes visible to recipient code.
  2. Allow or reject forwarding according to the integration's documented destination policy.
  3. Read the required final owner after the interaction, then reconcile the deposit ledger.
  4. 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.

Implementation-specific controls to add after the bounded token experiment
ControlExpected evidenceBoundary it tests
Wrong collection or direct forged callbackNo deposit creditToken identity and ledger authorization
Approved operator and owner routesOnly supported initiators acceptedOperator policy independent of previous owner
Forwarding or custom token overrideRequired final owner and liability agreeApplication custody condition
Recursive mint or claim attemptEntitlement and supply invariants holdExternal-call intermediate state
Authorized exit and unauthorized controlRelease succeeds only for the intended callerUsable 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.

Comments

0

    Leave a comment

    Share a question or observation about this article.

    10 to 3,000 characters.