Missing Access Control: How mint() Without onlyOwner Drains Tokens

A single missing modifier on mint() lets anyone inflate supply to infinity. SWC-105 has wiped out more token launches than any reentrancy bug — and most auditors still find it on page one.

Missing access control (SWC-105) occurs when privileged functions like mint(), burn(), withdraw(), or setOwner() lack permission checks such as onlyOwner or OpenZeppelin's AccessControl roles. Any external account can then call them, mint unlimited tokens, drain treasury funds, or transfer ownership. The fix is mechanical: add onlyOwner / onlyRole(MINTER_ROLE) to every state-changing function that should not be public, and write a unit test that calls each function from a non-privileged address and asserts a revert.
··7 min read

The Bug in One Line

A `mint()` function without an access modifier is a public faucet. Anyone with a wallet and 30 seconds can call it, inflate the supply, and dump on your liquidity pool before your team finishes lunch.

This is SWC-105 — Unprotected Ownership / Missing Access Control — and it remains the single most common finding in token contract audits. Not reentrancy. Not integer overflow. A missing four-character modifier.

Vulnerable Code

Here's the pattern, ripped from a hundred forked ERC20s:

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract VulnerableToken is ERC20 {
address public owner;

constructor() ERC20("Vuln", "VLN") {
owner = msg.sender;
_mint(msg.sender, 1_000_000 ether);
}

// No modifier. No msg.sender check. Nothing.
function mint(address to, uint256 amount) external {
_mint(to, amount);
}

function setOwner(address newOwner) external {
owner = newOwner;
}
}
```

Two separate disasters here:

1. `mint()` is callable by anyone. An attacker mints `2**255` tokens to themselves and instantly destroys the cap table.
2. `setOwner()` is callable by anyone. Even if you later add `onlyOwner` to `mint()`, an attacker can just grab ownership first.

The `owner` state variable is theater. Storing an address means nothing if no function actually compares against it.

The Fix

Use OpenZeppelin's battle-tested primitives. Don't roll your own.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract SafeToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

constructor(address admin) ERC20("Safe", "SFE") {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
_mint(admin, 1_000_000 ether);
}

function mint(address to, uint256 amount)
external
onlyRole(MINTER_ROLE)
{
_mint(to, amount);
}
}
```

For simple single-owner contracts, `Ownable` is fine:

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

contract SimpleToken is ERC20, Ownable {
constructor(address owner_) ERC20("S", "S") Ownable(owner_) {}

function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
```

`AccessControl` is preferable for production: it supports multiple roles, role admins, and on-chain enumeration. `Ownable` collapses every privileged action onto a single key — when that key gets phished, you lose everything.

Real-World Damage

This class of bug shows up constantly in rug pulls and post-mortems. A non-exhaustive sampler:

**PAID Network (March 2021)** — An attacker exploited an unprotected upgrade path to call a privileged mint, generating ~$166M in fake PAID tokens and dumping for roughly $3M in ETH before the team paused.
- **Countless BSC and Base meme-coin "rugs"** — Contracts ship with public `mint()` functions disguised as `_mint()`-style helpers. The deployer mints trillions after liquidity is added, sells into the pool, and walks away. On-chain forensics firms log these weekly.
- **DeFi bridge admin functions** — Multiple bridges have shipped `setValidator()` or `setTrustedRemote()` without role checks. Anyone overwrites the validator set and signs off on fake withdrawals.

The common thread: nobody reviewed every external function and asked "should a random EOA be able to call this?"

Detection Checklist

Before deploying, grep your contract for every `external` and `public` function and answer three questions:

1. Does this function change state?
2. If yes, should *anyone* be able to call it?
3. If no, is there a modifier — `onlyOwner`, `onlyRole`, `whenNotPaused`, or a manual `require(msg.sender == ...)` — gating it?

Functions that almost always need protection:

`mint`, `burn` (when not user-initiated)
- `setFee`, `setTreasury`, `setOracle`, `setRouter`
- `pause`, `unpause`
- `withdraw`, `rescueTokens`, `sweep`
- `upgradeTo`, `upgradeToAndCall`
- Anything starting with `set`, `update`, `transfer`, or `emergency`

Automated and Manual Detection

Static analyzers catch the obvious cases. Slither's `arbitrary-send-eth` and `suicidal` detectors flag missing access control on selfdestruct and ether-sending paths. Mythril and Slither both report `unprotected-upgrade` on UUPS proxies missing `_authorizeUpgrade`.

What they miss: business-logic access control. If your `rebalance()` should only be callable by a Gelato keeper but compiles fine without the check, no static tool will tell you that — it's a semantic question, not a syntactic one.

For a fast first pass, run our [free AI-powered audit](https://www.cryptohawking.com/audit). It catches the structural omissions in seconds. For production launches, the [manual audit](https://www.cryptohawking.com/audit/manual) (3 business days, $5K in ETH/SOL/USDT) gets a human auditor reading every privileged function against the intended threat model — which is the only reliable way to catch the semantic cases.

Tests That Actually Catch This

Write negative tests. Most teams only test the happy path.

```solidity
function test_mint_revertsForNonMinter() public {
address attacker = address(0xBEEF);
vm.prank(attacker);
vm.expectRevert();
token.mint(attacker, 1 ether);
}

function test_setOwner_revertsForNonOwner() public {
vm.prank(address(0xBEEF));
vm.expectRevert();
token.transferOwnership(address(0xBEEF));
}
```

Loop these tests over every privileged function. If you can't list every privileged function, that's the bug.

TL;DR

Missing access control is not a sophisticated exploit. It's a typo with consequences. Inherit `AccessControl` or `Ownable`, gate every state-changing function, write a revert test per function, and run a static analyzer before deploy. The bug is mechanical — there's no excuse for shipping it in 2025.

FAQ

What's the difference between Ownable and AccessControl?

`Ownable` gives you a single `owner` address and an `onlyOwner` modifier — simple, but it collapses every privilege onto one key. `AccessControl` lets you define arbitrary roles (`MINTER_ROLE`, `PAUSER_ROLE`, `UPGRADER_ROLE`) and grant them independently to different addresses, including multisigs and timelocks. For anything beyond a hobby token, use `AccessControl`. It supports role admins, on-chain enumeration via `AccessControlEnumerable`, and least-privilege architectures. Owning the mint key shouldn't also mean owning the upgrade key.

Is `require(msg.sender == owner)` enough, or do I need a modifier?

Functionally identical. A modifier is just sugar for an inline `require`. The reason modifiers win is consistency and auditability — when every privileged function ends in `onlyOwner`, missing one stands out visually during review. Inline `require` checks get copy-pasted, mistyped, or forgotten. Modifiers also centralize the check so changing the access logic (e.g., migrating from `Ownable` to a timelock) is a one-line edit. Use modifiers.

Does making a function `internal` protect it?

Yes, against direct external calls — `internal` and `private` functions cannot be called from outside the contract. The bug appears when an `external` wrapper exposes the internal function without an access check. OpenZeppelin's `ERC20._mint` is internal for exactly this reason: you, the developer, must write the external `mint()` and add the modifier. If you expose `_mint` via an unprotected external function, the internal visibility of `_mint` gives you zero protection.

How do I check an already-deployed contract for missing access control?

Pull the verified source from Etherscan, list every `external` and `public` function, and check each for a modifier or `msg.sender` comparison. For unverified contracts, decompile with Panoramix or use Dedaub's decompiler and look for `SSTORE` operations in functions without a sender check at the top. On-chain, you can simulate a call from a random address via Tenderly or Foundry's `cast call --from` — if the privileged function doesn't revert, it's unprotected.

Are upgradeable contracts more exposed to this bug?

Yes. UUPS proxies require you to override `_authorizeUpgrade(address)` with an access check. If you forget — and the OpenZeppelin wizard does not write it for you — anyone can call `upgradeTo()` and replace your implementation with malicious code. This has happened in production. Transparent proxies are slightly safer because the admin slot is checked by the proxy itself, but the implementation contract's initializer must be protected with `initializer` or `_disableInitializers()` to prevent takeover of the implementation address directly.

One Solidity tip + 1 case study per month