All audits
PassedEthereumIndependent

OZ Ownable2Step

Smart contract security audit by TierZero · 2024-02-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

0
Critical
0
High
1
Medium
2
Low
3
Informational

Independent research — not a commissioned audit.

What it is

Ownable2Step is OpenZeppelin's variant of the standard Ownable access-control pattern. Vanilla Ownable.transferOwnership(newOwner) writes newOwner to storage in a single transaction — the moment it's mined, the old owner is gone and the new address holds every privileged function, whether or not that address was ready, correct, or even able to call anything.

Ownable2Step splits that into two calls:

function transferOwnership(address newOwner) public onlyOwner {
    _pendingOwner = newOwner;
    emit OwnershipTransferStarted(owner(), newOwner);
}

function acceptOwnership() public {
    address sender = _msgSender();
    require(pendingOwner() == sender, "Ownable2Step: caller is not the new owner");
    _transferOwnership(sender);
}

The current owner nominates a successor; ownership only actually moves when that successor calls acceptOwnership() from an address that can sign transactions. It's a handshake, not a handoff.

This module has shipped in the OpenZeppelin Contracts library for years, is used by a large share of production Ethereum contracts (proxies, treasuries, token contracts, governance timelocks), and has been through the standard OZ audit and community-review process. Nothing here is new — this note explains the invariant and why it's worth defaulting to.

Threat model

The failure Ownable2Step targets isn't an attacker actively exploiting a bug — it's operator error with no recovery path:

  • A typo'd or truncated address passed to transferOwnership.
  • A copy-paste of the wrong checksum, or an address from the wrong chain (mainnet address pasted into an L2 deploy script, or vice versa).
  • Transferring to a multisig or contract address that has no function capable of calling owner-only methods back — a self-inflicted permanent lockout.
  • Transferring to an address nobody actually controls (a burn address, an exchange deposit address, a contract with no owner-facing logic).

With single-step Ownable, all four of these are unrecoverable the instant the transaction confirms. There is no revert path, no confirmation step, and no way to reclaim onlyOwner functions — including the ability to pause, upgrade, or withdraw funds, if those are gated behind ownership. This has bricked real deployed contracts; it's one of the most common "boring but fatal" incidents in production Solidity, distinct from anything requiring a hostile actor.

Why the design holds — key invariants & mitigations

The mechanism relies on one invariant: ownership state only changes when the candidate proves liveness and control by initiating a transaction from that exact address.

Concretely:

  1. _pendingOwner is separate storage from _owner. owner() keeps returning the current owner throughout the pending period — no window where onlyOwner checks resolve against an unconfirmed address.
  2. acceptOwnership() checks pendingOwner() == msg.sender, not an admin-supplied parameter, so the accepting party cannot be spoofed by anyone other than the address itself signing a transaction.
  3. The current owner can overwrite _pendingOwner at any time before acceptance (calling transferOwnership again with a different address, or a corrective address), so a bad nomination is cheaply reversible up until the counterpart accepts.
  4. _transferOwnership (inherited from Ownable) clears _pendingOwner on completion, closing the window rather than leaving a stale pending grant that could be accepted later under different circumstances.

The property this buys you is straightforward but easy to underrate: the two addresses that must independently confirm the change are cryptographically distinct principals — a proposer with onlyOwner and an acceptor with control of the private key (or multisig quorum) for the destination. A single fat-fingered transaction can no longer, by itself, cause irrecoverable loss of administrative control.

Where implementers get it wrong

  • Treating renounceOwnership() as covered by the same safety net. It isn't — Ownable2Step doesn't touch renounceOwnership, and OZ's own current guidance is to override it to revert unless renunciation is a deliberate, documented part of the design. Teams that inherit Ownable2Step for its transfer safety and forget this still have a one-step path to a permanently ownerless contract.
  • Mixing single-step and two-step patterns across a protocol. If a factory-deployed clone or a peripheral contract still inherits plain Ownable while the core contract uses Ownable2Step, operational runbooks that assume "ownership transfers always need acceptance" will misfire on the exception.
  • Not gating on-chain monitoring to OwnershipTransferStarted. The whole value of the pattern is a human or a monitoring system reviewing the pending transfer before it's accepted; teams that don't alert on the event get the storage-layout benefit but not the operational one.
  • Assuming the pending owner check protects against key compromise. It doesn't — if the destination address's key is compromised, the two-step flow adds no defense once acceptOwnership() is called from that key. It fixes address-selection error, not custody risk.
  • Custom reimplementations that skip the _pendingOwner reset. Forks that "simplify" the module by inlining logic sometimes drop the clear-on-accept step, leaving a stale pending grant an old counterpart can later claim.

These are the same class of composability and inheritance mistakes we look for in code review and audit engagements — the primitive is sound, but wiring it into a larger system reintroduces the exact failure modes it was built to remove. The approval-scoping mistakes we catalog in our review of Solidity Permit2 gasless-approval patterns and EVM sniping-bot exposure follow the same shape: a well-designed primitive undone by how it's integrated.

Takeaways

  • Default to Ownable2Step over Ownable for any contract where losing owner access has real consequences — the gas cost of one extra transaction on setup is trivial next to the cost of a bricked admin role.
  • Override renounceOwnership() explicitly; don't assume two-step transfer implies two-step (or blocked) renunciation.
  • Monitor OwnershipTransferStarted in production and treat pending transfers as a reviewable change, not a formality.
  • Two-step ownership doesn't replace multisig or timelock control for high-value contracts — it removes one specific, common failure mode (bad destination address), not the broader custody and governance surface.

If you're deciding between Ownable, Ownable2Step, or a timelocked multisig for a contract going to mainnet, that's exactly the kind of design decision worth a second set of eyes — see our smart contract audit services for how we scope that review.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit