Access control bugs quietly became the most expensive category of smart contract failure. OWASP’s 2026 Smart Contract Top 10 ranks access control vulnerabilities at #1, tying them to $953.2 million in documented losses, ahead of logic errors, reentrancy, and flash loan exploits combined. A separate 2026 audit findings report puts access control and authorization failures at roughly 35% of high-severity findings across audits conducted between 2025 and 2026. If you ship a Solidity contract this quarter, this is the bug class most likely to drain it.
This tutorial walks through building a real Foundry test suite that catches access control bugs before deployment: missing modifiers, unprotected initializers, tx.origin authentication, role misconfiguration, and proxy admin takeovers. You’ll write a vulnerable contract, break it with tests, fix it with OpenZeppelin’s AccessControl library, then wire the whole thing into CI so the checks run on every pull request. By the end you’ll have a complete working project you can drop into your own repo.
None of this requires exotic tooling. Foundry and Slither are both free, both run on a laptop, and both fit into a normal pull request workflow without slowing your team down. The gap most teams have isn’t access to tools, it’s a habit of testing for absence rather than presence: proving a function can’t be called by the wrong address is a different exercise than proving it works for the right one, and most test suites only do the second. This guide builds both, step by step, against a contract you can copy directly into your own repo today.
Why access control became DeFi’s #1 risk in 2026
Reentrancy dominated smart contract security headlines for years, going back to the 2016 DAO hack. That’s no longer the biggest line item on the loss sheet, and if reentrancy is still your team’s primary threat model, our reentrancy testing guide with Slither and Foundry covers that class separately. According to OWASP’s Smart Contract Top 10 project, access control vulnerabilities now sit at the top of the list by dollar losses, with reentrancy dropping to a smaller share of 2025’s total. The OWASP SC01 entry defines the category broadly: “improper access control describes any situation where a smart contract does not rigorously enforce who may invoke privileged behavior, under which conditions, and with which parameters,” and adds that “in modern DeFi systems this goes far beyond a single onlyOwner modifier.”
That framing matters because most teams still think of access control as a single line of code. A 2026 audit findings report breaks the category down further: missing function modifiers, uninitialized proxy ownership, role misconfiguration, tx.origin authentication bypass, and missing two-step ownership transfers all fall under the same umbrella. Each one requires a different kind of test. A single onlyOwner check on a withdraw function won’t catch a compromised executor role resetting a timelock delay to zero, which is exactly what happened in a 2026 vulnerability disclosure affecting OpenZeppelin’s TimelockController, where an actor holding the executor role could escalate privileges and take immediate control of the contract if that role was left open.
The audit gap compounds the problem. Industry estimates cited in 2026 coverage suggest fewer than 5% of deployed ERC-20 contracts have ever gone through a review by a recognized security firm. Contracts that do get audited fare dramatically better: one 2026 report states audited contracts see roughly 98% fewer successful exploits than unaudited contracts of comparable complexity and total value locked. Testing your own access control logic before an audit, or in place of one you can’t yet afford, closes a meaningful chunk of that gap. If you haven’t run a full review yet, pair this tutorial with our smart contract audit walkthrough for the broader process. That’s what the rest of this guide sets out to do.
It also helps to be precise about what “access control” actually spans, because the term gets used loosely. A 2026 audit findings report breaks high-severity findings into five recurring shapes: missing modifiers on individual functions, uninitialized proxy ownership that lets an attacker claim a freshly deployed contract, role hierarchies that grant more power than intended, tx.origin checks that a relayed call can defeat, and ownership transfer functions that hand control to a typo’d address in one step instead of two. Each of these needs a different test, which is why a single onlyOwner check on a withdraw function catches maybe one of the five.
| Vulnerability class | 2025 losses (OWASP 2026 Top 10) | Rank |
|---|---|---|
| Access control vulnerabilities | $953.2 million | #1 |
| Logic errors | $63.8 million | #2 |
| Reentrancy attacks | $35.7 million | #3 |
| Flash loan exploits | $33.8 million | #4 |
Prerequisites and tool versions
You don’t need a mainnet deployment or a paid audit tool to follow this tutorial. Everything runs locally. Install the following before you start, and pin the versions below in your project so your CI runs match what you test on your machine.
| Tool | Version used here | Install command |
|---|---|---|
| Foundry (forge, cast, anvil) | latest stable | curl -L https://foundry.paradigm.xyz | bash && foundryup |
| Slither | 0.11.5 or later | pip3 install slither-analyzer |
| Solidity compiler | 0.8.24 or later | set via solc_version in foundry.toml |
| OpenZeppelin Contracts | v5.4.0 | forge install OpenZeppelin/[email protected] |
| forge-std | latest | forge install foundry-rs/forge-std |
You’ll also want Python 3.9 or newer for Slither, and Git. If you’re on Windows, run everything through WSL2. Foundry’s toolchain assumes a POSIX shell, and native compilation fails in odd ways under plain PowerShell.
Step 1: Scaffold the Foundry test project
Start with a clean Foundry project and pull in the two dependencies you’ll need: OpenZeppelin’s contracts library for the fixed version later, and forge-std for cheatcodes like vm.prank and vm.expectRevert. The full cheatcode reference lives in the Foundry Book if you need anything beyond what’s covered here.
forge init access-control-lab
cd access-control-lab
forge install OpenZeppelin/[email protected]
forge install foundry-rs/forge-std
# point the compiler at 0.8.24+ and set remappings
echo 'solc_version = "0.8.24"' >> foundry.toml
echo "@openzeppelin/=lib/openzeppelin-contracts/" >> remappings.txt
Run forge build once to confirm the toolchain and remappings resolve cleanly before you write a single test. A broken remapping shows up as a confusing “source not found” error later, and it’s much easier to debug with an empty project.
Step 2: Inventory every privileged function
Before writing tests, list every function in your contract that changes state and ask who should be allowed to call it. This sounds tedious, but skipping it is how access control bugs slip through: a function gets added late in development, nobody updates the access control matrix, and it ships without a modifier. Grep your contract for external and public functions that aren’t view or pure, then build a table like the one below for your own contract.
| Attack surface | What to test | Foundry technique |
|---|---|---|
| Missing modifier | Every state-changing function reverts for a non-authorized caller | Negative-path unit test |
| Unprotected initializer | A second call to initialize() always reverts | Unit test plus fuzz test |
| tx.origin authentication | A contract-to-contract call can’t bypass the check | Attacker contract fixture |
| Role misconfiguration | DEFAULT_ADMIN_ROLE is transferred or renounced, never left open | Invariant test |
| Proxy admin takeover | Only a timelock or multisig can call upgradeTo | Unit test with vm.prank |
Here’s a deliberately vulnerable vault contract we’ll use through the rest of this tutorial. It has two bugs: withdrawAll has no access restriction at all, and initialize can be called more than once by anyone.
// src/VulnerableVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VulnerableVault {
address public owner;
mapping(address => uint256) public balances;
bool private initialized;
function initialize(address _owner) external {
// BUG: no check that this hasn't already run
owner = _owner;
initialized = true;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdrawAll(address to) external {
// BUG: missing onlyOwner-style modifier
uint256 bal = address(this).balance;
(bool ok, ) = to.call{value: bal}("");
require(ok, "transfer failed");
}
}
Step 3: Write negative-path access control tests
Most Foundry test suites focus on the happy path: does the function do what it’s supposed to when the right person calls it? Access control testing flips that around. You want to prove that the wrong caller can’t do the thing, not just that the right caller can. Write one negative test per privileged function, using vm.prank to impersonate an attacker address.
// test/AccessControl.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/VulnerableVault.sol";
contract AccessControlTest is Test {
VulnerableVault vault;
address owner = address(0xA11CE);
address attacker = address(0xBAD);
function setUp() public {
vault = new VulnerableVault();
vault.initialize(owner);
vm.deal(address(vault), 10 ether);
}
function test_RevertWhen_NonOwnerWithdraws() public {
vm.prank(attacker);
vm.expectRevert();
vault.withdrawAll(attacker);
}
function test_RevertWhen_ReinitializedByAttacker() public {
vm.prank(attacker);
vm.expectRevert();
vault.initialize(attacker);
}
}
Run forge test --match-contract AccessControlTest -vvv and watch both tests fail. That’s the point: the contract as written has no restrictions, so nothing reverts, and vm.expectRevert() catches the absence of a revert as a failure. This is your baseline. Every fix you make from here should turn a failing negative test into a passing one.
Step 4: Fuzz for missing modifiers
A single hardcoded attacker address only proves one address can’t call a function. Fuzzing proves no address can, short of the one you’ve authorized. Foundry generates hundreds of random addresses automatically when a test parameter is typed as address, so converting the negative-path test into a fuzz test costs almost nothing.
function testFuzz_OnlyOwnerCanWithdraw(address caller) public {
vm.assume(caller != owner);
vm.assume(caller != address(0));
vm.prank(caller);
vm.expectRevert();
vault.withdrawAll(caller);
}
Run this with a higher fuzz run count for anything security-critical: forge test --match-test testFuzz_OnlyOwnerCanWithdraw --fuzz-runs 10000. The default 256 runs is fine for iteration, but bump it before you trust the result. Fuzzing won’t catch logic that only breaks for a specific, structured input, like a role hash collision, so treat it as a complement to targeted unit tests, not a replacement.
Step 5: Test unprotected initializers on upgradeable contracts
Upgradeable contracts use an initialize function instead of a constructor, because proxies share storage with an implementation contract that never runs its own constructor in the proxy’s context. That pattern only works if initialize can run exactly once. If it can run twice, an attacker calls it, sets themselves as owner, and takes the contract. This is one of the most common access control bugs in production because it’s invisible in a code review that only checks for an onlyOwner modifier: the bug is the absence of a separate guard on setup itself.
OpenZeppelin’s Initializable base contract solves this with an initializer modifier that tracks whether setup has already run. Test both that the first call succeeds and that every subsequent call, from any address, reverts.
function test_InitializeCanOnlyRunOnce() public {
// first call already happened in setUp()
vm.expectRevert();
vault.initialize(attacker);
}
function testFuzz_InitializeAlwaysRevertsAfterFirstCall(address caller) public {
vm.prank(caller);
vm.expectRevert();
vault.initialize(caller);
}
If you’re using a proxy pattern (UUPS or Transparent), also test that calling initialize directly on the implementation contract, not through the proxy, either reverts or has no meaningful effect. OpenZeppelin’s newer templates call _disableInitializers() in the implementation’s constructor specifically to close this gap, and your test suite should confirm it’s actually wired in, not just present in the inherited base contract.
Step 6: Verify OpenZeppelin AccessControl roles
Single-owner patterns don’t scale past a certain point. Once you have more than one privileged action, role-based access control keeps permissions legible: a treasury role, a pauser role, a minter role, each granted independently instead of everything gating on one owner key. OpenZeppelin’s Contracts v5.x documentation covers the full AccessControl API, which implements this with bytes32 role identifiers and a built-in admin hierarchy. Here’s the fixed version of the vault using it.
// src/SecureVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
contract SecureVault is Initializable, AccessControl {
bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
function initialize(address admin) external initializer {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function deposit() external payable {}
function withdrawAll(address to) external onlyRole(WITHDRAWER_ROLE) {
uint256 bal = address(this).balance;
(bool ok, ) = to.call{value: bal}("");
require(ok, "transfer failed");
}
receive() external payable {}
}
Test that role grants and revocations behave exactly as intended, in both directions. It’s easy to test that granting a role works and forget to test that revoking it actually removes access, which is the direction that matters most when a key gets compromised.
function test_GrantedRoleCanWithdraw() public {
secureVault.grantRole(secureVault.WITHDRAWER_ROLE(), treasurer);
vm.prank(treasurer);
secureVault.withdrawAll(treasurer);
}
function test_RevertWhen_RevokedRoleWithdraws() public {
secureVault.grantRole(secureVault.WITHDRAWER_ROLE(), treasurer);
secureVault.revokeRole(secureVault.WITHDRAWER_ROLE(), treasurer);
vm.prank(treasurer);
vm.expectRevert();
secureVault.withdrawAll(treasurer);
}
Step 7: Simulate privilege escalation chains
OWASP’s 2026 guidance is explicit that access control now covers more than a single function check: “access control flaws allow unauthorized users or roles to invoke privileged functions or modify critical state, often leading to full protocol compromise when admin, governance, or upgrade paths are exposed.” The failure mode that matters most in practice isn’t a stranger calling a withdraw function directly. It’s a chain: a lower-privileged role uses a legitimate function to grant itself, or an ally, a higher-privileged role.
Test this by walking the role hierarchy in your contract and confirming that no role can grant a role above its own rank unless you explicitly designed it to. With OpenZeppelin’s default AccessControl, every role’s admin defaults to DEFAULT_ADMIN_ROLE unless you call _setRoleAdmin to change it, so a common mistake is granting a “manager” role admin rights over itself, which lets a manager add unlimited other managers.
function test_RevertWhen_ManagerGrantsManagerRole() public {
secureVault.grantRole(MANAGER_ROLE, manager);
vm.prank(manager);
vm.expectRevert();
// a manager should never be its own role's admin
secureVault.grantRole(MANAGER_ROLE, colludingAddress);
}
The same OWASP guidance warns about trust boundaries more broadly: “if any of these trust boundaries are weak or inconsistently applied, an attacker may be able to impersonate a privileged actor or cause the system to treat an untrusted address as if it were authorized.” In practice that includes tx.origin-based checks, which break the moment a victim interacts with an attacker-controlled contract that relays the call. Never use tx.origin for authorization. Test for it by writing an attacker contract that calls your vault’s function on behalf of a legitimate-looking origin, and confirm the call still fails.
Step 8: Audit proxy admin and upgrade paths
Upgradeable contracts introduce a second, entirely separate access control surface: who can point the proxy at a new implementation. OWASP’s SC10 guidance on this category is direct about the stakes: “when upgradeability is improperly secured, attackers can hijack the proxy admin or upgrade role to deploy malicious implementations, re-initialize contracts to seize ownership, or bypass critical checks in initialization or migration steps.” A contract can have flawless function-level access control and still be fully compromised if the upgrade mechanism itself isn’t locked down. If your protocol also bridges assets across chains, our cross-chain bridge security testing guide covers the parallel admin-key risks on that surface.
If you’re using UUPS proxies, the _authorizeUpgrade function is your enforcement point, and it’s easy to forget to override it with a real check, since the base implementation leaves it unguarded in some older templates. Test it directly.
function test_RevertWhen_NonAdminUpgrades() public {
address newImpl = address(new SecureVaultV2());
vm.prank(attacker);
vm.expectRevert();
UUPSUpgradeable(address(proxy)).upgradeToAndCall(newImpl, "");
}
function test_TimelockCanUpgrade() public {
address newImpl = address(new SecureVaultV2());
vm.prank(address(timelock));
UUPSUpgradeable(address(proxy)).upgradeToAndCall(newImpl, "");
// confirm implementation slot actually changed
assertEq(_getImplementation(address(proxy)), newImpl);
}
Route upgrade authority through a timelock, not a single EOA, and set a delay long enough for your community or team to notice and react to a malicious upgrade proposal before it executes. The 2026 TimelockController disclosure referenced earlier is a good reminder to also test the timelock’s own configuration: confirm the delay can’t be reset to zero by anything short of the role you intend, and that the executor role isn’t left open to any caller unless that’s a deliberate, documented choice.
Step 9: Run Slither static analysis
Foundry tests confirm behavior you thought to test for. Slither catches patterns you didn’t think to look for, by parsing the contract’s control flow directly. It ships with detectors tuned for access control issues: arbitrary external sends, suicidal contracts where anyone can trigger self-destruct, unprotected upgrade functions, and tx.origin usage. Run it against your project root, not just a single file, so it picks up inheritance correctly. Teams doing flash loan or price-manipulation testing on the same codebase can reuse most of this setup, as covered in our flash loan attack testing tutorial and our oracle manipulation testing guide.
pip3 install slither-analyzer
slither . \
--detect arbitrary-send-eth,suicidal,unprotected-upgrade,tx-origin,missing-zero-check \
--json slither-report.json
# print a human-readable summary too
slither . --print human-summary
Slither will flag things your tests miss, like an unprotected upgrade path or a zero-address check that’s missing on a role grant. It will also occasionally flag things that are fine given your specific design, so don’t treat every finding as a blocker. Triage each one, and if you deliberately accept a finding, suppress it inline with a comment explaining why, rather than silently ignoring the tool going forward.
Step 10: Write invariant tests for access control
Unit tests check one call at a time. Invariant tests check a property that must hold true no matter what sequence of calls Foundry’s fuzzer throws at your contract, including calls you didn’t anticipate. For access control, the property you care about most is usually simple to state and surprisingly hard to guarantee: the set of addresses holding privileged roles should never change except through the specific functions designed to change it.
// test/AccessControlInvariant.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/SecureVault.sol";
contract AccessControlInvariant is Test {
SecureVault vault;
address admin = address(0xA11CE);
function setUp() public {
vault = new SecureVault();
vault.initialize(admin);
targetContract(address(vault));
}
function invariant_AdminRoleNeverChangesUnexpectedly() public {
assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), admin));
}
function invariant_ContractHoldsNoUnauthorizedRoleGrants() public {
// fails if the fuzzer finds any sequence that grants
// WITHDRAWER_ROLE to an address that never went through
// an authorized grantRole call
assertLe(_countRoleHolders(vault.WITHDRAWER_ROLE()), _grantedCount);
}
}
Invariant tests take longer to write well because you need to track state across calls (the _grantedCount counter above would live in a handler contract in a real project), but they catch the class of bug that unit tests structurally can’t: a multi-step sequence of individually reasonable calls that ends in an unauthorized state. Increase fuzz.runs and set a realistic invariant.depth in foundry.toml for anything holding real value.
Step 11: Automate everything in CI
Tests that only run on your laptop protect exactly one commit: the one you remembered to test locally. Wire the Foundry suite and Slither into CI so every pull request runs both, and fail the build on either a test failure or a high-severity Slither finding.
# .github/workflows/access-control.yml
name: access-control-tests
on: [pull_request]
jobs:
foundry:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
- name: Run access control test suite
run: forge test --match-path "test/AccessControl*.t.sol" -vvv
- name: Install Slither
run: pip3 install slither-analyzer==0.11.5
- name: Run Slither
run: slither . --fail-high
Pin the Slither version in CI the same way you pin it locally. Detector behavior changes between releases, and a Slither upgrade that silently starts, or stops, flagging something in your contract shouldn’t happen in the middle of an unrelated pull request review. Bump the pinned version deliberately, on its own commit, so a new finding is easy to trace back to the tool update rather than your code.
Step 12: Triage, document, and fix findings
A test suite that catches bugs is only half the job. The other half is a process for what happens when it does. For every access control finding, whether from a failing test, a Slither flag, or a manual review, record three things before you touch the code: what the bug allows an attacker to do, which functions and roles it touches, and the minimal fix. Fixing the symptom by adding a modifier, without documenting the cause of why it was missing, tends to reproduce the same class of bug in the next contract your team ships.
Keep a running access control matrix as a markdown table in your repo, listing every privileged function, its required role, and the test file that covers it. Update it in the same pull request that adds or changes a privileged function. This single habit catches more missing-modifier bugs than any tool, because it forces the question of who should be allowed to call this to get asked at write time, instead of discovered at audit time or, worse, in production.
Severity matters here too, and not every finding deserves the same urgency. A missing modifier on a function that can drain the treasury is a same-day fix. A role hierarchy that technically allows a manager to grant itself admin, but only through a three-step sequence nobody would stumble into by accident, can usually wait for the next planned release, as long as you’ve written it down and assigned an owner. Treat your findings log the same way you’d treat a bug tracker: dated, assigned, and closed with a link to the commit and test that fixed it, so six months from now nobody has to reconstruct the reasoning from a Slack thread.
Common pitfalls when testing access control
- Testing only the happy path. A suite full of tests confirming the owner can withdraw, but none confirming a non-owner can’t, gives you false confidence. Every privileged function needs at least one negative test.
- Forgetting role revocation. Teams test that granting a role works and never test that revoking it actually removes access. This matters most exactly when you need it most: after a key compromise.
- Assuming inherited modifiers are wired up correctly. Inheriting from
AccessControlorOwnabledoesn’t protect a function by itself. The modifier has to be applied to each function individually, and it’s easy to add a new function and forget it. - Using tx.origin for any authorization check. It looks like a shortcut for confirming the original caller, but any contract the victim interacts with can relay a call through their own address, making tx.origin checks trivially bypassable.
- Skipping proxy-level tests because function-level tests pass. Function access control and upgrade access control are separate surfaces. A contract can lock down every function perfectly and still be one bad
_authorizeUpgradeaway from a full takeover. - Leaving DEFAULT_ADMIN_ROLE unrenounced on a deployer EOA. The deployer address often keeps admin rights after launch by default, unless the team explicitly transfers or renounces them, which turns a single leaked private key into a full protocol compromise.
Troubleshooting guide
- “Initializable: contract is already initialized” appears even on the first call. You’re likely calling
initializethrough the implementation address instead of the proxy, or a parent contract’s constructor already called an initializer internally. Check your inheritance chain for a duplicate initializer call. - vm.expectRevert() passes even though the function should succeed. Confirm you’re not still impersonating the previous test’s
vm.prankaddress. Foundry’s prank only applies to the next call unless you usevm.startPrank, which persists untilvm.stopPrank. - Slither reports “unprotected-upgrade” on a contract you believe is protected. Slither checks whether
_authorizeUpgradecontains an access control check it recognizes. A custom check using a non-standard pattern, like a mapping lookup instead of a role or owner check, can go undetected. Rewrite it to use a recognizable modifier or file a Slither suppression comment with justification. - Fuzz tests pass locally but fail in CI with a different counterexample. Pin a fuzz seed in
foundry.tomlfor CI reproducibility, or increase run count locally to match CI so you’re not comparing a 256-run local pass against a 10,000-run CI run. - Invariant tests time out or never finish in CI. Lower
invariant.depthandinvariant.runsfor CI-speed runs, and keep a separate, deeper nightly job for the full sweep. Invariant testing at production depth can take minutes per run. - grantRole succeeds for an address that shouldn’t have admin rights. Check
getRoleAdminfor the role in question. If you never called_setRoleAdmin, every role defaults to being administered byDEFAULT_ADMIN_ROLE, which may not be the hierarchy you intended. - forge test can’t find OpenZeppelin imports. Confirm remappings.txt points at the correct lib path and that you ran
forge installwith the exact tag, not just the default branch, since import paths can shift between major versions. - Slither hangs or crashes on a large codebase. Run it against a specific contract with
--filter-pathsto narrow scope first, and confirm your Solidity compiler version infoundry.tomlmatches what Slither expects, since version mismatches are a common cause of parser failures.
Advanced tips for production-grade coverage
Once the basics are in place, a few practices separate a test suite that looks thorough from one that actually holds up under adversarial pressure. First, write a dedicated attacker contract fixture that exercises every relay pattern you can think of: delegatecall, low-level call, and a contract that forwards msg.sender in different ways. Testing against plain EOAs alone misses bugs that only surface when the caller is itself a contract.
Second, run a differential check between your access control matrix (the markdown table from Step 12) and your actual test file names using a small script in CI. If a privileged function exists in the matrix without a matching test, fail the build. This turns documentation drift into a hard CI failure instead of a silent gap.
Third, if your contract touches meaningful value, budget for a second, independent set of eyes beyond your own test suite. A 2026 report on smart contract audit economics notes that projects using continuous automated tooling alongside manual review report substantially fewer vulnerabilities than teams relying on either approach alone. Your Foundry suite and Slither runs are the automated layer. Treat them as a strong floor, not a ceiling. And remember that contract-level access control is only one half of the security picture: the admin keys and multisig signers who hold your roles need the same discipline covered in our self-custody wallet setup guide, since a compromised admin key bypasses every test in this tutorial regardless of how well the contract code is written. For broader coverage of the space, see our cryptocurrency security hub.
Complete working project structure
Putting every piece from this tutorial together, your final project should look like this. Each file maps directly to a step above, so you can trace any test back to the vulnerability it’s built to catch.
access-control-lab/
├── foundry.toml
├── remappings.txt
├── src/
│ ├── VulnerableVault.sol # Step 2: intentionally broken baseline
│ └── SecureVault.sol # Step 6: fixed with AccessControl
├── test/
│ ├── AccessControl.t.sol # Steps 3-5: negative-path + fuzz
│ ├── RoleManagement.t.sol # Steps 6-7: roles + escalation
│ ├── ProxyUpgrade.t.sol # Step 8: upgrade authorization
│ └── AccessControlInvariant.t.sol # Step 10: invariant tests
├── docs/
│ └── access-control-matrix.md # Step 12: living permission table
└── .github/workflows/
└── access-control.yml # Step 11: CI wiring
Run the full suite with forge test -vvv before every commit that touches a privileged function, and let CI catch anything that slips past local testing. Combined with the Slither scan from Step 9, this project structure gives you layered coverage: unit tests for known attack patterns, fuzz tests for the address space you didn’t enumerate by hand, invariant tests for multi-step sequences, and static analysis for patterns your tests never thought to check.
Clone this structure for a new contract by copying the test directory and swapping in your own contract’s function names and roles. The bulk of the work each time is Step 2, the inventory of privileged functions, since that’s specific to whatever you’re building. Everything downstream of it, the negative-path pattern, the fuzz wrapper, the invariant handler, and the CI job, is close to boilerplate you can reuse project after project with minor edits.
Frequently asked questions
What exactly counts as an access control vulnerability in a smart contract?
Any case where a contract fails to properly restrict who can call a privileged function, under what conditions, and with what parameters. That covers missing modifiers, unprotected initializers on upgradeable contracts, tx.origin-based authentication, misconfigured role hierarchies, and unsecured proxy upgrade paths.
How much has access control cost the industry in 2026?
OWASP’s 2026 Smart Contract Top 10 attributes $953.2 million in 2025 losses to access control vulnerabilities, ranking it the #1 attack vector by dollar amount, ahead of logic errors, reentrancy, and flash loan exploits.
What’s the difference between a plain onlyOwner modifier and OpenZeppelin’s AccessControl?
onlyOwner gives you a single privileged address for the whole contract. AccessControl supports multiple named roles, each with its own admin hierarchy, so different functions can require different permissions without collapsing everything onto one owner key.
Can Slither catch access control bugs on its own, without a Foundry test suite?
It catches known patterns, like unprotected upgrade functions or tx.origin usage, but it can’t verify your intended permission model. A missing modifier that Slither doesn’t have a detector for, or a role hierarchy that’s technically valid Solidity but wrong for your design, needs targeted tests to catch.
Do I need 100% test coverage for access control to be safe?
Coverage percentage measures whether a line executed, not whether the right assertions ran against it. A function can be covered by a happy-path test and still have zero negative-path tests. Track access control coverage separately, function by function, against the matrix described in Step 12.
What’s the practical difference between unit tests and invariant tests here?
Unit tests check a single call in isolation, like whether a specific attacker address can call withdrawAll. Invariant tests check a property across many random sequences of calls, catching bugs that only appear after a specific multi-step chain of otherwise-valid actions.
How do I test that only a timelock can upgrade my proxy?
Deploy your timelock contract in the test setup, prank as an unauthorized address and confirm the upgrade call reverts, then prank as the timelock address and confirm it succeeds. Also test the timelock’s own configuration, since a misconfigured delay or executor role can undermine the protection even if the proxy-level check is correct.
Should this kind of testing replace a professional audit?
No. Treat it as the floor an auditor should be able to build on, not a substitute for one. A 2026 report on audited versus unaudited contracts found roughly 98% fewer successful exploits among audited contracts of comparable complexity, and testing your own access control logic thoroughly before an audit tends to make that audit faster and cheaper, since reviewers spend less time on issues your own suite already caught.




