Web3 Security 101Part 2 of 12SmartAlert5 min read

Owners, roles and admin keys: where the door is

How OpenZeppelin Ownable and AccessControl decide who can mint, pause or upgrade, why one admin key is a single point of failure, and what to watch.

*Part 2 of 12 in "Web3 Security 101". Part 1 showed that a contract is code plus state and that some functions are open to everyone. Here: the functions that are not, and who holds the keys to them.*

Most contracts have a few functions that must not be public: change a fee, pause trading, mint tokens, replace the code, move the treasury. The contract protects them with an access check. The check compares msg.sender with something stored in state. Whoever matches that stored value holds the door.

So the security of a contract is, to a large degree, the security of one question: who is allowed to change the state that says who is allowed? Two OpenZeppelin patterns cover most contracts12.

Ownable: one owner

Ownable stores a single address and gives you a modifier:

solidity
import "@openzeppelin/contracts/access/Ownable.sol";

contract Pool is Ownable {
    constructor(address initialOwner) Ownable(initialOwner) {}

    function setFee(uint256 fee) external onlyOwner { /* ... */ }
}

The owner can call every function marked onlyOwner. Three more functions are built in1:

  • transferOwnership(newOwner) gives the whole door to another address. It emits OwnershipTransferred(previousOwner, newOwner).
  • renounceOwnership() sets the owner to the zero address. Every onlyOwner function is then dead, forever.
  • Ownable2Step is a safer variant. The transfer needs two steps: the current owner proposes (OwnershipTransferStarted), the new owner accepts (OwnershipTransferred). A typo in the address can no longer hand the contract to nobody.

AccessControl: many roles

AccessControl replaces one owner with named roles, each holding a set of addresses2:

solidity
import "@openzeppelin/contracts/access/AccessControl.sol";

contract Token is AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    constructor(address admin) {
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
    }

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { /* ... */ }
}

Each role has an admin role that can grant and revoke it. By default that is DEFAULT_ADMIN_ROLE, the value 0x00. Whoever holds it can give MINTER_ROLE to any address. In practice DEFAULT_ADMIN_ROLE is the master key, and every other role is only as safe as the holder of this one.

The events are RoleGranted(role, account, sender), RoleRevoked(...) and RoleAdminChanged(...).

What "admin key" means in practice

The owner or admin is an address. An address is controlled by one of three things:

  1. A regular account (EOA), controlled by one private key. If the key leaks, the attacker has the door. This is the most common setup at launch, because the deployer account becomes the first owner.
  2. A multisig such as a Safe, where several people must sign. Part 4 covers it.
  3. A contract such as a timelock, which delays every admin action by a fixed period so that others can react.

The first option is the risky one. A single key is stored somewhere: a laptop, a hardware wallet, a CI secret. Phishing, malware or a leaked backup is enough. The onlyOwner check will not stop the attacker, because he is the owner now.

Three cases from our write-ups

In each case the attacker did not find a bug. The attacker used the door.

  • IoTeX ioTube, February 2026. An ownership signal appeared 14 minutes before the first withdrawal from the bridge. Read the write-up.
  • Wasabi Protocol, April 2026. A deployer key granted ADMIN_ROLE 108 seconds before the vaults emptied. The attacker then upgraded the proxies, and everything was gone within 28 minutes. Read the write-up.
  • DxSale, May 2026. Three lockers were transferred to a new owner. Then 45 hours of silence. Then liquidity left over 1,400 pools. Read the write-up.

What they share: the dangerous step is a small, quiet administrative call, and it comes before the loss. In DxSale the gap was almost two days. In Wasabi it was under two minutes. How much an alert helps depends on that gap, and on whether the team has a lever left when it reads the message, for example a pause().

Questions to ask about your own contract

  • Who is the owner or DEFAULT_ADMIN_ROLE holder today? Read it from the chain, do not rely on memory.
  • Is it an EOA, a multisig or a timelock?
  • Can a role be revoked? In OpenZeppelin 2.x-era token contracts (MinterRole), a minter can add other minters, and only the minter itself can give the role up. Nobody can revoke it, and you have no way out.
  • Is there a pause, and who can call it?
  • Do you use Ownable2Step, so a typo cannot orphan the contract?

How to catch it

Everything below works from the contract's ABI and events, as SmartAlert does today3.

  1. Ownership. Add the contract. If the ABI has OwnershipTransferred, SmartAlert offers the ownership-transferred template, severity critical, plus ownership-transfer-started if the contract emits OwnershipTransferStarted. Recipe: Alert when contract ownership is transferred.
  2. Roles. For AccessControl, the role-granted and role-revoked templates, and role-admin-changed if the event is in the ABI. Recipe: Alert when a role is granted or revoked. Most teams change roles a few times a year, so each alert is worth reading. For old OpenZeppelin 2.x token roles, the minter-added, minter-removed and pauser-added templates apply.
  3. Pauses. paused and unpaused. Recipe: Alert when a contract is paused. The state version, paused() returns true, is a second signal on Pro and above.
  4. Allowed senders. A rule on OwnershipTransferred or RoleGranted with a list of addresses that are allowed to cause it. It fires when the transaction's sender or recipient is not on the list. It catches a call made directly or through another contract, and it also counts failed attempts, so a stranger's rejected call to an admin function is visible4.

What these rules cannot do. They tell you the door was used, not whether the use was legitimate: a planned handover and a theft look the same, so the alert should lead to a person who knows the plan. They also arrive after the block is confirmed, about 2.5 to 4 minutes on Ethereum. If the attacker already holds the key and acts in the same block, an alert is a notice, not a stop.

Next, Part 3: Upgradeable proxies: why the code behind an address can change.

Sources

  1. 1.OpenZeppelin Contracts, Access: Ownable, Ownable2Step. ↩
  2. 2.OpenZeppelin Contracts, Access Control. ↩
  3. 3.SmartAlert, rule templates by contract type: Ownable, AccessControl, Pausable; see the recipes. ↩
  4. 4.SmartAlert, "allowed senders" rule: a transaction is allowed if its sender or its recipient is in the list; failed transactions are counted. ↩

Try it free

An address is enough. The first 14 days get Pro limits, and rules are checked on history before they go live.