What is Access Control?
Access control determines who can call specific functions in your smart contract. Without proper access control, anyone can execute sensitive operations like withdrawing funds, minting tokens, or changing critical parameters.
Impact
Common Vulnerabilities
🔓 Missing Access Modifiers
Forgetting to add onlyOwner or similar modifiers to sensitive functions.
🔓 Unprotected Initializers
In upgradeable contracts, anyone can call initialize() if not protected.
🔓 Incorrect msg.sender Checks
Using tx.origin instead of msg.sender, or wrong comparison logic.
🔓 Default Visibility
Functions without explicit visibility default to public in older Solidity versions.
Missing Access Modifiers
The most common mistake is simply forgetting to add access control to admin functions:
contract VulnerableToken {
address public owner;
mapping(address => uint256) public balances;
// ❌ Anyone can call this!
function mint(address to, uint256 amount) external {
balances[to] += amount;
}
// ❌ Anyone can withdraw all funds!
function withdrawAll() external {
payable(msg.sender).transfer(address(this).balance);
}
// ❌ Anyone can become owner!
function setOwner(address newOwner) external {
owner = newOwner;
}
}contract SecureToken {
address public owner;
mapping(address => uint256) public balances;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// ✅ Only owner can mint
function mint(address to, uint256 amount) external onlyOwner {
balances[to] += amount;
}
// ✅ Only owner can withdraw
function withdrawAll() external onlyOwner {
payable(owner).transfer(address(this).balance);
}
// ✅ Only owner can transfer ownership
function setOwner(address newOwner) external onlyOwner {
require(newOwner != address(0), "Zero address");
owner = newOwner;
}
}Improper Role Validation
Even when access control exists, it can be implemented incorrectly:
1// ❌ VULNERABLE: Using == instead of require
2contract BadValidation {
3 address public admin;
4
5 function sensitiveAction() external {
6 // This doesn't revert! It just does nothing if not admin
7 if (msg.sender == admin) {
8 // do something
9 }
10 // Execution continues even for non-admins!
11 }
12}
13
14// ❌ VULNERABLE: Using tx.origin
15contract TxOriginVulnerable {
16 address public owner;
17
18 function withdraw() external {
19 // Vulnerable to phishing! See tx.origin article
20 require(tx.origin == owner, "Not owner");
21 payable(tx.origin).transfer(address(this).balance);
22 }
23}
24
25// ❌ VULNERABLE: Uninitialized owner
26contract UninitializedOwner {
27 address public owner; // Defaults to address(0)
28
29 modifier onlyOwner() {
30 require(msg.sender == owner, "Not owner");
31 _;
32 }
33
34 // If owner is never set, no one can call this
35 // Or worse, owner might match a burnt address
36 function mint(address to, uint256 amount) external onlyOwner {
37 // ...
38 }
39}
40
41// ✅ SECURE: Proper initialization
42contract ProperInit {
43 address public owner;
44
45 constructor() {
46 owner = msg.sender; // Set in constructor
47 }
48
49 // Or use initializer pattern for proxies
50}Best Practices
✅ Use OpenZeppelin
Battle-tested access control contracts with Ownable, AccessControl, and more.
✅ Two-Step Ownership
Require new owner to accept transfer, preventing accidental lockouts.
✅ Role-Based Access
Use granular roles instead of a single owner for better security.
✅ Explicit Visibility
Always declare function visibility explicitly (public, external, internal, private).
1// Two-step ownership transfer pattern
2contract TwoStepOwnable {
3 address public owner;
4 address public pendingOwner;
5
6 event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
7 event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
8
9 modifier onlyOwner() {
10 require(msg.sender == owner, "Not owner");
11 _;
12 }
13
14 constructor() {
15 owner = msg.sender;
16 }
17
18 // Step 1: Current owner initiates transfer
19 function transferOwnership(address newOwner) external onlyOwner {
20 require(newOwner != address(0), "Zero address");
21 pendingOwner = newOwner;
22 emit OwnershipTransferStarted(owner, newOwner);
23 }
24
25 // Step 2: New owner must accept
26 function acceptOwnership() external {
27 require(msg.sender == pendingOwner, "Not pending owner");
28 emit OwnershipTransferred(owner, pendingOwner);
29 owner = pendingOwner;
30 pendingOwner = address(0);
31 }
32
33 // Emergency: Cancel pending transfer
34 function cancelTransfer() external onlyOwner {
35 pendingOwner = address(0);
36 }
37}Using OpenZeppelin AccessControl
For complex protocols, use role-based access control instead of simple ownership:
1import "@openzeppelin/contracts/access/AccessControl.sol";
2
3contract SecureProtocol is AccessControl {
4 // Define roles as constants
5 bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
6 bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
7 bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
8
9 constructor() {
10 // Grant deployer the default admin role
11 _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
12 _grantRole(ADMIN_ROLE, msg.sender);
13 }
14
15 // Only addresses with MINTER_ROLE can call
16 function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
17 _mint(to, amount);
18 }
19
20 // Only addresses with PAUSER_ROLE can call
21 function pause() external onlyRole(PAUSER_ROLE) {
22 _pause();
23 }
24
25 // Only addresses with ADMIN_ROLE can call
26 function setFee(uint256 newFee) external onlyRole(ADMIN_ROLE) {
27 fee = newFee;
28 }
29
30 // Role hierarchy example:
31 // - DEFAULT_ADMIN_ROLE can grant/revoke all roles
32 // - ADMIN_ROLE can manage protocol settings
33 // - MINTER_ROLE can only mint tokens
34 // - PAUSER_ROLE can only pause/unpause
35}