DeFi Access Control Patterns: A Cross-Chain Checklist

The most common critical vulnerability in smart contract audits is not reentrancy. It is not oracle manipulation. It is a function that should be restricted but is callable by anyone.
This sounds trivial. It is not. Access control bugs account for more total value stolen than any single vulnerability class except oracle manipulation. The reason is simple: a missing access control check on a withdrawal function, a parameter update function, or an upgrade function gives an attacker direct, immediate access to funds or critical state.
Every chain has its own idioms for access control. The mistakes are different across EVM, Solana, and CosmWasm because the execution models are different. But the root cause is always the same: the developer forgot to check who is calling.
EVM Access Control
The Basics: Ownable and AccessControl
For simple ownership (one admin address), use OpenZeppelin's Ownable2Step:
import "@openzeppelin/contracts/access/Ownable2Step.sol";
contract Vault is Ownable2Step {
function emergencyWithdraw() external onlyOwner {
// Only the owner can call this
}
}
Ownable2Step requires the new owner to explicitly accept ownership, preventing accidental transfers to wrong addresses. Use it over Ownable for anything holding value.
For complex role systems (multiple roles with different permissions), use AccessControl:
import "@openzeppelin/contracts/access/AccessControl.sol";
contract Protocol is AccessControl {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE");
function pause() external onlyRole(PAUSER_ROLE) { /* ... */ }
function setFee(uint256 fee) external onlyRole(FEE_MANAGER_ROLE) { /* ... */ }
}
Common EVM Access Control Mistakes
1. Missing modifier on admin functions:
// WRONG: anyone can call
function setOracle(address newOracle) external {
oracle = newOracle;
}
// RIGHT: restricted to owner
function setOracle(address newOracle) external onlyOwner {
oracle = newOracle;
}
2. Using tx.origin instead of msg.sender:
// WRONG: tx.origin can be any EOA that initiated the transaction chain
require(tx.origin == admin, "Unauthorized");
// An attacker can trick the admin into calling their contract,
// which then calls this function. tx.origin is still the admin.
// RIGHT: use msg.sender
require(msg.sender == admin, "Unauthorized");
3. Unprotected initializers on upgradeable contracts:
// WRONG: anyone can call initialize and set themselves as admin
function initialize(address _admin) external {
admin = _admin;
}
// RIGHT: use OpenZeppelin's initializer modifier
function initialize(address _admin) external initializer {
admin = _admin;
}
4. public visibility where internal or external was intended:
// WRONG: helper function is publicly accessible
function _updateRewards(address user) public {
// This can be called by anyone, potentially gaming reward calculations
}
// RIGHT: internal helper
function _updateRewards(address user) internal {
// Only callable by this contract's functions
}
Solana Access Control (Anchor)
Solana's account model makes access control fundamentally different. Every account passed to an instruction is an opportunity for validation, and every missing validation is a potential exploit.
Signer Checks
The most basic check: is the right account signing this transaction?
// WRONG: any signer passes
#[derive(Accounts)]
pub struct AdminAction<'info> {
pub admin: Signer<'info>, // verifies someone signed, not WHO signed
#[account(mut)]
pub config: Account<'info, Config>,
}
// RIGHT: verify the signer is the expected admin
#[derive(Accounts)]
pub struct AdminAction<'info> {
#[account(
constraint = admin.key() == config.admin @ ErrorCode::Unauthorized
)]
pub admin: Signer<'info>,
#[account(mut)]
pub config: Account<'info, Config>,
}
has_one Constraints
When the expected signer is stored in an account, use has_one:
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
pub authority: Signer<'info>,
#[account(
mut,
has_one = authority @ ErrorCode::Unauthorized
)]
pub vault: Account<'info, Vault>,
// vault.authority must equal authority.key()
}
Common Solana Access Control Mistakes
1. Missing ownership checks on deserialized accounts:
// WRONG: AccountInfo has no ownership verification
pub user_data: AccountInfo<'info>, // could be any program's account
// RIGHT: Account<T> verifies owner == this program
pub user_data: Account<'info, UserData>,
2. Missing program ID checks on CPI targets:
// WRONG: token_program could be an attacker's program
pub token_program: AccountInfo<'info>,
// RIGHT: Program<T> validates the program ID
pub token_program: Program<'info, Token>,
3. PDA seeds missing user context:
// WRONG: any user can access any user's account
#[account(seeds = [b"user_data"], bump)]
pub user_data: Account<'info, UserData>,
// RIGHT: PDA is user-specific
#[account(
seeds = [b"user_data", user.key().as_ref()],
bump,
)]
pub user_data: Account<'info, UserData>,
CosmWasm Access Control
CosmWasm access control is built around MessageInfo, which contains the sender address and attached funds for every execute message.
The Basic Pattern
pub fn execute_admin_action(
deps: DepsMut,
info: MessageInfo,
params: AdminParams,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
if info.sender != config.admin {
return Err(ContractError::Unauthorized {});
}
// ... admin logic
Ok(Response::new())
}
Using cw-ownable
For standardized ownership management:
use cw_ownable::{assert_owner, initialize_owner};
pub fn instantiate(deps: DepsMut, info: MessageInfo, msg: InstantiateMsg) -> Result<Response, ContractError> {
initialize_owner(deps.storage, deps.api, Some(&info.sender.to_string()))?;
Ok(Response::new())
}
pub fn execute_admin_action(deps: DepsMut, info: MessageInfo) -> Result<Response, ContractError> {
assert_owner(deps.storage, &info.sender)?;
// ... admin logic
Ok(Response::new())
}
Common CosmWasm Access Control Mistakes
1. Missing info parameter in execute handler:
// WRONG: no way to check the caller
pub fn set_config(deps: DepsMut, new_fee: Decimal) -> Result<Response, ContractError> {
// Anyone can call this because there is no info.sender check
CONFIG.update(deps.storage, |mut c| -> StdResult<_> {
c.fee = new_fee;
Ok(c)
})?;
Ok(Response::new())
}
2. Checking the wrong field for authorization:
// WRONG: checking attached funds instead of sender identity
if info.funds.is_empty() {
return Err(ContractError::Unauthorized {});
}
// Anyone who sends funds can call this
3. Missing address validation:
// WRONG: storing raw string without validation
pub fn update_admin(deps: DepsMut, info: MessageInfo, new_admin: String) -> Result<Response, ContractError> {
assert_owner(deps.storage, &info.sender)?;
// new_admin could be malformed or empty
CONFIG.update(deps.storage, |mut c| -> StdResult<_> {
c.admin = Addr::unchecked(new_admin.clone()); // unchecked!
Ok(c)
})?;
Ok(Response::new())
}
// RIGHT: validate the new admin address
let validated_admin = deps.api.addr_validate(&new_admin)?;
The Universal Access Control Checklist
Regardless of chain, verify these for every function in your contract:
For every state-changing function:
- Is there an access control check? If not, is it intentionally public? Document why.
- Does the check verify the specific identity (which admin), not just that someone authenticated?
- Can the check be bypassed during initialization or through a proxy?
For admin/privileged functions:
- Is ownership transfer a two-step process (propose + accept)?
- Are there timelocks on the most dangerous admin actions (upgrade, parameter changes)?
- Are admin capabilities documented? Can the admin rug users?
For token/fund operations:
- Can only authorized addresses withdraw funds?
- Are approval and allowance patterns correct and non-exploitable?
- Are there caps or rate limits on privileged withdrawals?
For upgrades and migrations:
- Is the upgrade function restricted to the correct role?
- Can the upgrade mechanism itself be upgraded or disabled?
- Are initializers protected against re-initialization?
Odin Scan checks access control patterns across EVM, Solana, and CosmWasm. It flags missing signer checks, unprotected admin functions, tx.origin usage, missing has_one constraints, and unvalidated addresses. Start your free trial and catch access control gaps before they become exploits.