← Back to Blog

Solidity Modifier Ordering: Reentrancy Guards, Access Control, and Check-Effects-Interactions

2026-04-18 modifier nonReentrant access control check effects interactions ordering solidity security

Modifier ordering is one of the most underappreciated security dimensions in Solidity. Developers often treat modifiers as interchangeable guards that can be stacked in any order, but the Solidity compiler executes them left-to-right, with each modifier running its pre-condition code before passing control to the next. That sequencing changes the security properties of the function entirely.

Security auditors check modifier ordering explicitly because the mistakes are subtle and the consequences are severe. A reentrancy guard placed second instead of first can be bypassed. An access control check placed after expensive computation lets anyone drain gas from your node. A pausable check before an ownership check leaks information about your contract's state. None of these bugs appear in a simple unit test, and none are caught by tools that only check modifier presence rather than modifier position.

This post covers six modifier ordering anti-patterns, each with vulnerable code, an explanation of the attack vector, and the corrected version.


1. nonReentrant After a State-Mutating Modifier

The nonReentrant modifier from OpenZeppelin works by setting a lock before the function body executes and clearing it after. If a modifier that runs before nonReentrant makes an external call or emits events that trigger callbacks, the lock has not been set yet. An attacker can reenter through that modifier's external call before nonReentrant ever fires.

Vulnerable:

contract VaultWithOracle {
    mapping(address => uint256) public balances;
    IPriceOracle public oracle;
    bool private _locked;

    modifier updatePrice() {
        // Makes external call to oracle BEFORE nonReentrant lock is set
        uint256 price = oracle.getLatestPrice(); // external call here
        require(price > 0, "Invalid price");
        _;
    }

    modifier nonReentrant() {
        require(!_locked, "Reentrant call");
        _locked = true;
        _;
        _locked = false;
    }

    // nonReentrant runs AFTER updatePrice — lock is not set during oracle call
    function withdraw(uint256 amount) external updatePrice nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }
}

When a malicious oracle or a compromised oracle contract calls back into withdraw, _locked is still false because nonReentrant's pre-condition has not run yet. The attacker drains the vault through repeated reentrant calls before the lock engages.

Fixed:

contract VaultWithOracle {
    mapping(address => uint256) public balances;
    IPriceOracle public oracle;
    bool private _locked;

    modifier nonReentrant() {
        require(!_locked, "Reentrant call");
        _locked = true;
        _;
        _locked = false;
    }

    modifier updatePrice() {
        uint256 price = oracle.getLatestPrice();
        require(price > 0, "Invalid price");
        _;
    }

    // nonReentrant is FIRST — lock is set before any external call
    function withdraw(uint256 amount) external nonReentrant updatePrice {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }
}

The rule is simple: nonReentrant must be the first modifier on any function that could involve external calls, including external calls inside other modifiers.


2. onlyOwner Checked After Expensive Computation

When an access control modifier like onlyOwner is placed after a computationally expensive modifier, every caller — authorized or not — triggers the expensive computation. The function reverts at the access control check, but the gas for the computation has already been consumed.

Vulnerable:

contract MerkleDistributor {
    bytes32 public merkleRoot;
    address public owner;

    modifier verifyMerkleProof(bytes32[] calldata proof, address account, uint256 amount) {
        bytes32 leaf = keccak256(abi.encodePacked(account, amount));
        require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
        _;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    // Merkle verification runs first — anyone can force expensive computation
    function adminClaim(
        bytes32[] calldata proof,
        address account,
        uint256 amount
    ) external verifyMerkleProof(proof, account, amount) onlyOwner {
        _distribute(account, amount);
    }
}

An attacker submits repeated calls with valid-looking but incorrect proofs. Each call burns gas running the Merkle verification before hitting the onlyOwner revert. At scale, this can make the function economically unusable for the legitimate owner and imposes costs on any node executing these transactions.

Fixed:

contract MerkleDistributor {
    bytes32 public merkleRoot;
    address public owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    modifier verifyMerkleProof(bytes32[] calldata proof, address account, uint256 amount) {
        bytes32 leaf = keccak256(abi.encodePacked(account, amount));
        require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
        _;
    }

    // Access control first — unauthorized callers are rejected cheaply
    function adminClaim(
        bytes32[] calldata proof,
        address account,
        uint256 amount
    ) external onlyOwner verifyMerkleProof(proof, account, amount) {
        _distribute(account, amount);
    }
}

Access control modifiers must always be the outermost guard. They act as a cheap filter so expensive operations are only paid for by callers who are authorized to proceed.


3. whenNotPaused Before onlyOwner Leaks Contract State

Placing whenNotPaused before onlyOwner on privileged functions creates an information leak. When the contract is paused, a call reverts with the paused error before reaching the ownership check. An attacker can probe which addresses receive the "Paused" revert versus the "Not owner" revert to confirm which address is the owner without that address ever broadcasting a transaction.

Vulnerable:

contract PausableVault {
    address public owner;
    bool public paused;

    modifier whenNotPaused() {
        require(!paused, "Contract is paused");
        _;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Caller is not the owner");
        _;
    }

    // whenNotPaused runs first — revert message reveals pause status before auth
    function emergencyWithdraw(address token) external whenNotPaused onlyOwner {
        IERC20(token).transfer(owner, IERC20(token).balanceOf(address(this)));
    }
}

When paused == true, any caller targeting emergencyWithdraw gets "Contract is paused." When the contract is not paused, non-owners get "Caller is not the owner." An attacker calling from a guessed address while the contract is paused learns nothing — but calling from the actual owner address while paused yields "Contract is paused" instead of "Caller is not the owner," confirming the owner identity. This is especially harmful for contracts whose owner address is meant to be confidential.

Fixed:

contract PausableVault {
    address public owner;
    bool public paused;

    modifier onlyOwner() {
        require(msg.sender == owner, "Caller is not the owner");
        _;
    }

    modifier whenNotPaused() {
        require(!paused, "Contract is paused");
        _;
    }

    // onlyOwner first — unauthorized callers never learn the pause state
    function emergencyWithdraw(address token) external onlyOwner whenNotPaused {
        IERC20(token).transfer(owner, IERC20(token).balanceOf(address(this)));
    }
}

4. Modifiers That Assume Clean State Before Other Modifiers

Modifiers sometimes read or write the same storage slots. When modifier A writes a value that modifier B reads, and both are on the same function, the ordering determines whether B sees the pre-write or post-write value. Getting it wrong silently bypasses logic that looks correct in isolation.

Vulnerable:

contract RateLimitedProtocol {
    mapping(address => uint256) public lastCallTime;
    uint256 public cooldown = 1 hours;

    modifier updateLastCall() {
        lastCallTime[msg.sender] = block.timestamp; // writes first
        _;
    }

    modifier rateLimited() {
        // reads lastCallTime — but updateLastCall already set it to now
        require(
            block.timestamp >= lastCallTime[msg.sender] + cooldown,
            "Rate limit active"
        );
        _;
    }

    // updateLastCall runs before rateLimited — rate limit is always satisfied
    function sensitiveAction() external updateLastCall rateLimited {
        _executeAction();
    }
}

Because updateLastCall runs first and sets lastCallTime[msg.sender] to block.timestamp, the rateLimited modifier sees the freshly updated timestamp. block.timestamp >= block.timestamp + cooldown is always false — the rate limit never triggers. The function can be called unlimited times per block.

Fixed:

contract RateLimitedProtocol {
    mapping(address => uint256) public lastCallTime;
    uint256 public cooldown = 1 hours;

    modifier rateLimited() {
        require(
            block.timestamp >= lastCallTime[msg.sender] + cooldown,
            "Rate limit active"
        );
        _;
    }

    modifier updateLastCall() {
        lastCallTime[msg.sender] = block.timestamp;
        _;
    }

    // rateLimited checks BEFORE updateLastCall writes — rate limit enforced correctly
    function sensitiveAction() external rateLimited updateLastCall {
        _executeAction();
    }
}

Document modifier interdependencies explicitly in comments. Any modifier that reads a value that another modifier on the same function writes is order-sensitive and must be treated as a coupling contract.


5. Inherited Modifier Overridden Without Understanding Solidity's Limits

Solidity does not support virtual modifiers in the same way it supports virtual functions. When a derived contract attempts to override a modifier from a base contract, the base contract's own functions continue using the base modifier definition. The override only applies to functions defined in the derived contract.

Vulnerable:

contract BaseVault {
    address public owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    function adminAction() external onlyOwner {
        _doAdminWork();
    }
}

contract MultiSigVault is BaseVault {
    address[] public signers;
    mapping(bytes32 => uint256) public approvals;

    // Developer thinks this overrides onlyOwner everywhere — it does not
    modifier onlyOwner() override {
        bytes32 txHash = keccak256(abi.encodePacked(msg.sig, msg.data));
        require(approvals[txHash] >= 2, "Requires multi-sig");
        _;
    }

    // adminAction() in BaseVault still uses the original onlyOwner check
    // A single owner signature bypasses multi-sig on inherited functions
}

BaseVault.adminAction resolves onlyOwner at the point of its own definition. Even after MultiSigVault overrides the modifier, calling adminAction on a MultiSigVault instance executes BaseVault.onlyOwner, not the multi-sig version. The security upgrade is silently not applied to inherited functions.

Fixed:

contract BaseVault {
    function _checkOwner() internal virtual {
        require(msg.sender == owner(), "Not owner");
    }

    modifier onlyOwner() {
        _checkOwner();
        _;
    }

    function owner() public view virtual returns (address);

    function adminAction() external onlyOwner {
        _doAdminWork();
    }
}

contract MultiSigVault is BaseVault {
    address[] public signers;
    mapping(bytes32 => uint256) public approvals;

    // Override the internal function — now adminAction() uses multi-sig check
    function _checkOwner() internal override {
        bytes32 txHash = keccak256(abi.encodePacked(msg.sig, msg.data));
        require(approvals[txHash] >= 2, "Requires multi-sig");
    }
}

Use virtual internal functions as the implementation point for modifiers. Derived contracts override the function, which the inherited modifier already calls, so all inherited functions pick up the new logic correctly.


6. Check-Effects-Interactions Violated via Post-Condition Modifier

The check-effects-interactions (CEI) pattern requires that state changes happen before external calls. A modifier that uses _ in the middle — running some code before the function body and some code after — can silently violate CEI even when the function body itself looks compliant. The modifier's post-condition code runs after the external call in the function body.

Vulnerable:

contract BalanceTracker {
    mapping(address => uint256) public pendingWithdrawals;
    mapping(address => uint256) public totalWithdrawn;

    modifier trackWithdrawal(uint256 amount) {
        require(pendingWithdrawals[msg.sender] >= amount, "Insufficient");
        _; // function body runs here — including the external call
        // post-condition: runs AFTER the external call
        totalWithdrawn[msg.sender] += amount;
        pendingWithdrawals[msg.sender] -= amount; // state update after external call
    }

    function withdraw(uint256 amount) external trackWithdrawal(amount) {
        // Developer thinks CEI is followed — but the modifier updates state after
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }
}

Execution order: modifier pre-condition check → external call (via function body) → modifier post-condition state update. An attacker's receive function reenters withdraw before pendingWithdrawals[msg.sender] is decremented. The check in the modifier pre-condition passes again because the balance has not been updated yet. The attacker drains the contract.

Fixed:

contract BalanceTracker {
    mapping(address => uint256) public pendingWithdrawals;
    mapping(address => uint256) public totalWithdrawn;
    bool private _locked;

    modifier nonReentrant() {
        require(!_locked, "Reentrant call");
        _locked = true;
        _;
        _locked = false;
    }

    function withdraw(uint256 amount) external nonReentrant {
        // Checks
        require(pendingWithdrawals[msg.sender] >= amount, "Insufficient");
        // Effects — state updated before external call
        pendingWithdrawals[msg.sender] -= amount;
        totalWithdrawn[msg.sender] += amount;
        // Interactions
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }
}

Avoid post-condition modifiers that update balances or financial state. Move those updates into the function body before any external call. If a modifier must have post-condition logic, it should only be used for non-financial bookkeeping and must be combined with nonReentrant.


What ContractScan Detects

ContractScan analyzes modifier ordering as part of its static and semantic analysis pipeline, catching these bugs before they reach mainnet.

Vulnerability Detection Method Severity
nonReentrant after state-mutating modifier Data flow analysis traces external calls in modifiers; flags when nonReentrant is not leftmost Critical
onlyOwner after expensive computation Call graph analysis identifies high-gas operations before access control checks High
whenNotPaused before onlyOwner on privileged functions Modifier order pattern matching on functions with both access control and pause guards Medium
Modifier state read/write ordering conflicts Shared storage slot analysis across all modifiers on a single function High
Inherited modifier override without virtual function pattern Inheritance graph traversal checks modifier resolution against developer intent High
CEI violation via post-condition modifier Control flow analysis identifies state mutations in modifier post-conditions after external calls Critical

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →