Securite·10 min de lecture·Par Solingo

Solidity Security Best Practices — Ship Safe Smart Contracts

The essential security checklist for every Solidity developer. From access control to reentrancy guards.

# 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:

  • Owner calls malicious contract
  • Malicious contract calls VulnerableWallet.transfer()
  • tx.origin is still the owner, so check passes
  • Funds stolen
  • 8. 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:

  • Use latest Solidity (0.8.0+)
  • Checks-Effects-Interactions pattern
  • ReentrancyGuard for external calls
  • Access control on sensitive functions
  • Input validation everywhere
  • Safe arithmetic (limit unchecked)
  • msg.sender over tx.origin
  • Pull payments instead of push
  • Rate limiting for abuse prevention
  • Emergency pause for critical contracts
  • Established libraries (OpenZeppelin)
  • Professional audits before deploying value
  • 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.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement