# Solidity Security Best Practices — Ship Safe Smart Contracts
Smart contract security is non-negotiable. Unlike traditional software, bugs in deployed smart contracts can lead to irreversible loss of funds. This guide covers the 12 essential security practices every Solidity developer must follow.
Over $3 billion was lost to smart contract hacks in 2025. Don't let your contract be next. Let's build secure code from day one.
1. Use the Latest Solidity Version
Rule: Always use Solidity 0.8.0 or higher.
Why: Version 0.8.0 introduced automatic overflow/underflow checks, eliminating an entire class of vulnerabilities.
// ❌ BAD - Vulnerable to overflow
pragma solidity ^0.7.6;
contract VulnerableToken {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount; // Can underflow!
balances[to] += amount; // Can overflow!
}
}
// ✅ GOOD - Protected by default
pragma solidity ^0.8.26;
contract SafeToken {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount; // Reverts on underflow
balances[to] += amount; // Reverts on overflow
}
}
Best practice: Pin to a specific version for production (pragma solidity 0.8.26;) to avoid unexpected behavior from compiler updates.
2. Follow Checks-Effects-Interactions Pattern
Rule: Order your code: checks → effects → interactions.
Why: Prevents reentrancy attacks by ensuring state changes happen before external calls.
// ❌ BAD - Vulnerable to reentrancy
function withdraw() external {
uint256 amount = balances[msg.sender];
// Interaction BEFORE effect - DANGEROUS!
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0; // Effect happens too late
}
// ✅ GOOD - Checks-Effects-Interactions
function withdraw() external {
// CHECK
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// EFFECT
balances[msg.sender] = 0;
// INTERACTION
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
The DAO hack (2016, $60M stolen) exploited exactly this vulnerability.
3. Use Reentrancy Guards
Rule: Protect state-changing external calls with a reentrancy guard.
Why: Even with proper patterns, complex interactions can create reentrancy vectors.
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function deposit() external payable nonReentrant {
balances[msg.sender] += msg.value;
}
}
How it works: The nonReentrant modifier sets a lock that prevents the function from being called again before it completes.
4. Implement Robust Access Control
Rule: Use OpenZeppelin's access control patterns or role-based systems.
Why: Prevents unauthorized users from calling sensitive functions.
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
// Simple ownership
contract SimpleVault is Ownable {
function emergencyWithdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
}
// Role-based access control (better for complex systems)
contract AdvancedVault is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ADMIN_ROLE, msg.sender);
}
function setFee(uint256 newFee) external onlyRole(ADMIN_ROLE) {
// Only admins can set fees
}
function processBatch() external onlyRole(OPERATOR_ROLE) {
// Only operators can process batches
}
}
Pro tip: For contracts managing significant funds, use a multisig wallet (like Gnosis Safe) as the owner.
5. Validate All Inputs
Rule: Never trust user input. Validate everything.
Why: Invalid inputs can break invariants or cause unexpected behavior.
contract TokenSwap {
uint256 public constant MAX_SLIPPAGE = 500; // 5%
uint256 public constant MIN_AMOUNT = 1e15; // 0.001 tokens
function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minAmountOut,
uint256 deadline
) external {
// Validate addresses
require(tokenIn != address(0), "Invalid tokenIn");
require(tokenOut != address(0), "Invalid tokenOut");
require(tokenIn != tokenOut, "Same token");
// Validate amounts
require(amountIn >= MIN_AMOUNT, "Amount too small");
require(minAmountOut > 0, "Invalid min amount");
// Validate deadline
require(block.timestamp <= deadline, "Expired");
// Validate slippage
uint256 expectedOut = getExpectedOutput(tokenIn, tokenOut, amountIn);
uint256 slippage = ((expectedOut - minAmountOut) * 10000) / expectedOut;
require(slippage <= MAX_SLIPPAGE, "Slippage too high");
// Proceed with swap...
}
}
Common validations:
- Non-zero addresses
- Amount bounds (min/max)
- Array length limits
- Deadline checks
- Slippage protection
6. Handle Arithmetic Safely
Rule: Be explicit about overflow behavior. Use unchecked only when absolutely safe.
Why: Even with 0.8+ checks, unchecked blocks can introduce vulnerabilities.
// ✅ GOOD - Safe usage of unchecked
function calculateFee(uint256 amount) public pure returns (uint256) {
unchecked {
// This can never overflow because percentage < 10000
return (amount * 30) / 10000; // 0.3% fee
}
}
// ❌ BAD - Dangerous unchecked usage
function addPoints(uint256 current, uint256 points) public pure returns (uint256) {
unchecked {
return current + points; // Can overflow!
}
}
// ✅ GOOD - Let compiler check
function addPoints(uint256 current, uint256 points) public pure returns (uint256) {
return current + points; // Reverts on overflow
}
Division by zero: Always check divisor is non-zero or use SafeMath equivalents.
function divide(uint256 a, uint256 b) public pure returns (uint256) {
require(b > 0, "Division by zero");
return a / b;
}
7. Avoid tx.origin for Authorization
Rule: Use msg.sender for authorization, never tx.origin.
Why: tx.origin can be exploited via phishing attacks.
// ❌ BAD - Phishing vulnerable
contract VulnerableWallet {
address public owner;
function transfer(address to, uint256 amount) external {
require(tx.origin == owner, "Not owner"); // WRONG!
// Attacker can trick owner into calling malicious contract
payable(to).transfer(amount);
}
}
// ✅ GOOD - Secure authorization
contract SecureWallet {
address public owner;
function transfer(address to, uint256 amount) external {
require(msg.sender == owner, "Not owner"); // CORRECT
payable(to).transfer(amount);
}
}
Attack scenario:
VulnerableWallet.transfer()tx.origin is still the owner, so check passes8. Use Pull Payment Pattern
Rule: Let users withdraw funds instead of pushing payments to them.
Why: Failed transfers to contracts can lock funds or enable griefing attacks.
// ❌ BAD - Push payments (can fail)
contract Auction {
function endAuction() external {
// This can fail if highestBidder is a contract that reverts
payable(highestBidder).transfer(highestBid);
}
}
// ✅ GOOD - Pull payment pattern
contract SecureAuction {
mapping(address => uint256) public pendingWithdrawals;
function endAuction() external {
// Record the withdrawal instead of sending directly
pendingWithdrawals[highestBidder] += prize;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
OpenZeppelin provides PullPayment utility for this pattern.
9. Implement Rate Limiting
Rule: Limit sensitive operations to prevent abuse and flash loan attacks.
Why: Unrestricted operations can be exploited in single transactions.
contract RateLimitedVault {
uint256 public constant WITHDRAWAL_LIMIT = 100 ether;
uint256 public constant COOLDOWN = 1 days;
mapping(address => uint256) public lastWithdrawal;
mapping(address => uint256) public dailyWithdrawn;
function withdraw(uint256 amount) external {
require(amount > 0, "Invalid amount");
// Check cooldown
if (block.timestamp >= lastWithdrawal[msg.sender] + COOLDOWN) {
dailyWithdrawn[msg.sender] = 0;
}
// Check daily limit
require(
dailyWithdrawn[msg.sender] + amount <= WITHDRAWAL_LIMIT,
"Daily limit exceeded"
);
dailyWithdrawn[msg.sender] += amount;
lastWithdrawal[msg.sender] = block.timestamp;
// Process withdrawal...
}
}
10. Add Emergency Pause Functionality
Rule: Include a circuit breaker for critical contracts.
Why: Allows you to pause operations if a vulnerability is discovered.
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract EmergencyVault is Pausable, Ownable {
mapping(address => uint256) public balances;
function deposit() external payable whenNotPaused {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external whenNotPaused {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// Emergency functions (only owner)
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
}
Important: Document the pause mechanism clearly and consider using a timelock for unpausing.
11. Use Established Libraries
Rule: Don't reinvent the wheel. Use battle-tested libraries like OpenZeppelin.
Why: These libraries have been audited extensively and are used by thousands of projects.
// ✅ GOOD - Use OpenZeppelin
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC20, ERC20Burnable, Ownable {
constructor() ERC20("MyToken", "MTK") Ownable(msg.sender) {
_mint(msg.sender, 1000000 * 10 ** decimals());
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
Recommended libraries:
- OpenZeppelin Contracts: Tokens, access control, utilities
- Solady: Gas-optimized implementations
- Chainlink: Oracles, VRF, automation
- Uniswap V3: Math libraries (TickMath, FullMath)
12. Audit Before Deploying
Rule: Get a professional audit for contracts managing significant value.
Why: Even experienced developers make mistakes. Fresh eyes catch issues.
Pre-audit checklist:
- [ ] Write comprehensive tests (>90% coverage)
- [ ] Run static analysis (Slither, Mythril)
- [ ] Check with Foundry invariant tests
- [ ] Verify all dependencies are up-to-date
- [ ] Document all assumptions and trust assumptions
- [ ] Perform internal code review
Audit firms:
- Trail of Bits
- OpenZeppelin
- ConsenSys Diligence
- Certora
- Quantstamp
Cost: $5k-$100k+ depending on complexity.
Alternative: Public audit contests on Code4rena or Sherlock (crowdsourced).
Security Checklist Summary
- [ ] Use Solidity 0.8.0+ (built-in overflow protection)
- [ ] Follow Checks-Effects-Interactions pattern
- [ ] Add ReentrancyGuard to state-changing external calls
- [ ] Implement access control (Ownable/AccessControl)
- [ ] Validate all inputs (addresses, amounts, deadlines)
- [ ] Handle arithmetic carefully (avoid unnecessary unchecked)
- [ ] Never use tx.origin for authorization
- [ ] Prefer pull over push payments
- [ ] Implement rate limiting for sensitive operations
- [ ] Add emergency pause mechanism
- [ ] Use audited libraries (OpenZeppelin, etc.)
- [ ] Get professional audit before mainnet deployment
Additional Resources
- Smart Contract Weakness Classification: https://swcregistry.io/
- OpenZeppelin Security: https://docs.openzeppelin.com/contracts/5.x/api/security
- Trail of Bits Best Practices: https://github.com/crytic/building-secure-contracts
- Consensys Smart Contract Best Practices: https://consensys.github.io/smart-contract-best-practices/
- Solingo Security Course: Deep dive into exploits and prevention
Conclusion
Security is not optional in smart contract development. Follow these 12 best practices religiously:
Remember: Code is audited once, but exploited forever. Take security seriously from day one.
Solingo's Security Track teaches you to think like an attacker and build like a defender. Learn to identify vulnerabilities, exploit them in safe environments, and implement bulletproof protections. Secure your future — start learning today.