Solidity Smart Contract Security Best Practices for Production Systems
A practical reference for Solidity smart contract security best practices — covering access control, reentrancy, input validation, and safe external calls.
Security failures in smart contracts are not rare edge cases. They are predictable outcomes of skipping known patterns. Most exploits that drain real funds exploit vulnerabilities that were documented years before the attack — reentrancy, unchecked return values, broken access control. If you are building a system that will hold user funds or control critical on-chain logic, these practices are not optional.
Solidity Smart Contract Security Best Practices
1. Follow the Checks-Effects-Interactions Pattern
Every function that interacts with an external address should follow this order:
- Checks — validate inputs and state conditions (
require,revert) - Effects — update your contract's internal state
- Interactions — call external contracts or send ETH
If you send ETH or call an external contract before updating your own state, a malicious contract can re-enter your function and exploit the inconsistent state. This is reentrancy — and it has drained hundreds of millions of dollars across the ecosystem.
2. Use ReentrancyGuard on Vulnerable Functions
OpenZeppelin's ReentrancyGuard adds a mutex that prevents a function from being called while it is still executing. Apply it to any function that sends ETH or calls an untrusted external contract:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
// safe to interact with external addresses here
}
}
This is a belt-and-suspenders measure. Even if you follow Checks-Effects-Interactions perfectly, the guard adds a layer of defense.
3. Restrict Access with Role-Based Controls
Not every function should be callable by everyone. Use Ownable for single-owner contracts or AccessControl for multi-role systems. OpenZeppelin implements both correctly — do not reinvent this:
import "@openzeppelin/contracts/access/AccessControl.sol";
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
Avoid tx.origin for authorization. It is vulnerable to phishing attacks where a malicious contract tricks a privileged EOA into calling it, which then calls your contract with the EOA's tx.origin. Always use msg.sender.
4. Validate All Inputs
Never assume callers will pass valid data. Validate every parameter at the function entry point:
- Check that addresses are not
address(0) - Verify amounts are within sensible bounds
- Confirm that array lengths match when you process paired arrays
- Use
requirewith descriptive error messages (or custom errors for gas efficiency)
Custom errors cost less gas than string reverts and are just as readable in traces:
error InvalidAmount(uint256 provided, uint256 maximum);
if (amount > maxAllowed) revert InvalidAmount(amount, maxAllowed);
5. Handle ETH Transfers Safely
transfer() and send() are considered legacy patterns. They forward a fixed 2300 gas stipend which is insufficient for recipient contracts that perform logic on receive. The recommended pattern:
(bool success, ) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");
Always check the return value. Always update state before the call.
6. Be Careful with External Contract Calls
Calling into an address you do not control is dangerous. The callee can:
- Revert and cause your transaction to fail
- Re-enter your contract
- Consume arbitrary gas
If the external address is user-supplied, treat it as adversarial. Use interface casting to limit what functions you expose, and consider wrapping external calls in try/catch to handle failures gracefully rather than propagating a revert.
7. Avoid On-Chain Randomness from Block Data
block.timestamp, block.number, and blockhash are manipulable by validators to a degree. Do not use them as entropy sources for anything that controls value (lotteries, NFT reveals, winner selection). Use Chainlink VRF or a commit-reveal scheme instead.
8. Pin Dependencies and Compiler Versions
Using a specific OpenZeppelin version (e.g., 4.9.6) and a pinned compiler (e.g., pragma solidity 0.8.28;) means your contract behavior does not change as dependencies update. Floating ranges like ^0.8.0 introduce risk across the codebase.
9. Emit Events for All State Changes
Events create an auditable trail of what happened and when. Any state change that affects user funds or permissions should emit an event with the relevant context. This makes incident response faster and off-chain indexing accurate.
10. Do Not Deploy Without a Static Analysis Pass
Tools like Slither (from Trail of Bits) run in seconds and catch a large class of common issues automatically. Run slither . in your Foundry or Hardhat project before every deploy. Treat its output as a required sign-off, not an optional step.
Before You Deploy to Mainnet
Security best practices reduce risk but do not eliminate it. For contracts that will hold material value, you need an independent security audit by professionals who specialize in smart contract review. An audit is not a guarantee — it is a structured adversarial review that catches patterns automated tools miss.
The cost of an audit is small compared to the cost of an exploit. Build the audit into your project timeline, not as an afterthought.
If you need a smart contract system built with these practices embedded in the engineering process from day one, Clixo builds and ships production contract systems for founders and product teams.