Lesson 6 — Access control: onlyOwner, roles, multisig, signatures
Most catastrophic exploits aren't clever — they're a missing modifier on an admin function. Today: the access-control surface area, including the EIP-712 signature mistakes that have drained protocols.
The single most embarrassing class of smart-contract exploit is the missing-modifier bug: an admin function that lets anyone become the owner because the developer forgot the `onlyOwner` modifier. These bugs are responsible for tens of millions of dollars of losses across the years. They're also the easiest class to audit for — which is why this lesson focuses not just on the simple case but on the subtler access-control bugs that even careful auditors miss.
**The simple case: missing modifier.** A function that should be restricted has no `require` or modifier. Any address can call it. Concrete losses: the LeetSwap deployer-key bug, the Audius UUPS initialiser, multiple smaller protocols. The defence is mechanical: every state-changing function not intended for public use must have either a modifier or an inline `require(msg.sender == ...)`. Automated tools (Slither, Mythril) catch the egregious cases; audit reports flag them at high severity.
**Role-based access control (RBAC).** OpenZeppelin's `AccessControl` provides a multi-role system: `DEFAULT_ADMIN_ROLE` plus arbitrary custom roles, each role-gated function checks `hasRole(role, msg.sender)`. RBAC is more expressive than a single owner but introduces its own bug class: developers granting too-broad roles, or assigning the same role to multiple addresses where the principle of least privilege would say otherwise. Reading a contract's `grantRole` / `revokeRole` history on-chain is part of due diligence.
**Ownership transfer pitfalls.** A `transferOwnership(newOwner)` that takes effect immediately can lock out a contract if `newOwner` is a typo'd address with no private key. The defensive pattern is two-step: `transferOwnership` proposes the change; `acceptOwnership` from the new owner finalises it. OpenZeppelin's `Ownable2Step` implements this; many older contracts use the unsafe immediate variant. Audit reports often recommend the two-step pattern.
**Multisig and timelock for sensitive actions.** For high-stakes administrative operations (upgrade, parameter change, emergency drain), a single owner key is a single point of failure. The convention is a multisig (e.g., Gnosis Safe 3-of-5) calling a timelock (e.g., 48-hour delay) that calls the protocol. This adds two safety properties: (1) no single key can act unilaterally; (2) any planned action has a public delay window during which users can exit if they disagree. Protocols without these are increasingly seen as taking on counterparty risk for users.
**EIP-712 signed messages.** EIP-712 defines a standard for off-chain typed-data signatures that on-chain contracts can verify. Used heavily in gasless approvals (ERC-20 Permit), Uniswap's swap signatures, Compound governance vote delegation, etc. The bug surface: forgetting to include nonce + deadline + chain ID in the signed payload allows signature replay across protocols, transactions, or chains. The DAI Permit signature has been exploited multiple times by phishers because users sign permits without understanding what they're signing.
**Signature replay across chains.** A signature valid for a contract on Ethereum is byte-equal valid on a fork (Optimism, Arbitrum, BSC) unless the chain ID is part of the signed payload. EIP-712 mandates chain ID in the domain separator for this reason. Contracts that implement custom signature schemes without chain ID separation have produced replay-attack losses.
**The `ecrecover` precompile.** Solidity exposes `ecrecover(hash, v, r, s) returns (address)` to recover a signer's address from a signature. The bug: `ecrecover` returns `address(0)` for malformed inputs, and a check like `require(ecrecover(...) == knownSigner)` passes if both `ecrecover` returns zero and `knownSigner` is zero. Always `require(signer != address(0))` first. Multiple protocols have had bypasses through this `address(0)` quirk.
Example
Two contrasting bugs from real protocols. (a) Audius (July 2022, ~$6M loss): an upgradeable proxy contract had an `initialize()` function that set up governance. The initialiser was meant to be called once at deployment but lacked an `initializer` modifier; an attacker called it again, took over governance, and authorised a malicious upgrade that drained tokens. The fix would have been one line: `function initialize() public initializer { ... }`. (b) The Nomad bridge (August 2022, $190M): the bridge's `proveAndProcess` function relied on a Merkle root stored in a trusted variable; due to an improper initialisation during an upgrade, the trusted root was set to `0x00`, which matched the trivial proof for every message — anyone could call `proveAndProcess` with any payload and the bridge would process it as legitimate. Both bugs are access-control bugs in different forms: (a) missing modifier on an init function, (b) trust assumption violated by an initialisation mistake.
Common mistakes
- Missing modifier on a sensitive function. The single most-exploited bug class.
- Confusing `private` (visibility) with 'access-controlled.' Private only prevents external callers; nothing about authorisation.
- Using immediate ownership transfer. A typo locks the contract; use Ownable2Step.
- Forgetting `require(signer != address(0))` after `ecrecover`. Malformed signatures recover to zero, and zero-comparison checks pass.
- Trusting EIP-712 signatures without verifying nonce + deadline + chain ID in the signed payload. Replay attacks across protocols and chains.
Check your understanding
An upgradeable contract has `function initialize(address admin) public { _admin = admin; }` with no `initializer` modifier. The deploy script calls `initialize` once with the legitimate admin. What is the risk?
Key terms covered
Sources & further reading
- Primary
- Primary
- Primary
- Secondary
We prioritise primary sources. Where a topic moves quickly (regulation, security incidents), we re-check sources on the cadence shown by the page's "Next review" date.