
12 Solidity Smart Contract Security Best Practices
Written by Usman Asim

Smart contracts are the wizards behind blockchain's magic. They are the code blocks that automate onchain logic, specifying everything from the liquidation thresholds of decentralized lending protocols to the tokenization of real world assets, and much more.
But with great power comes great responsibility, especially when it comes to security. One bug can lead to massive exploits, and nobody wants their project to be the next headline for a multimillion dollar hack.
In the first half of 2025 alone, over $2.3 billion in crypto was lost to exploits and breaches, with access control issues alone accounting for over $1.6 billion of that pain. If you're building with Solidity on Ethereum or similar chains, nailing security isn't optional: it's what keeps your users' funds safe and your reputation intact.
In this post, we'll break down what smart contract security really means, dive into some of the biggest contract vulnerabilities, and share practical best practices with code examples to help you bulletproof your code and deploy safe contracts on mainnet.
And remember, always test on testnets first before mainnet deployments. It’s far better to find vulnerabilities before your users start sending funds to your contract.
What Is Smart Contract Security?
Smart contracts are self executing pieces of code that run when certain conditions are met. Think automated agreements that handle transfers, votes, or even complex DeFi maneuvers without middlemen. They're deployed as bytecode on the blockchain, and once live, they're immutable, so any flaw in a contract is there for the public to see.
Security here boils down to making sure that code is rock solid because often contracts themselves custody onchain assets. Can hackers drain funds from a particular contract? Does the contract logic hold up under weird edge cases? Are assets locked away from unauthorized access?
A vulnerability in those answers means a malicious actor could drain the contract of user funds and take that money for themself. To protect those vulnerabilities, you need to be thinking about implementing input checks, design patterns, and code audits that can harden your code before mainnet deployment.
For a deeper intro, check out the Ethereum docs on smart contract security.
Why Security Matters for You
Building insecure contracts isn't just risky for users, it can tank your project overnight. When a project gets drained, users lose faith, and they often don’t come back. Blockchain's irreversibility means that once funds are gone, they're gone. There are no chargebacks here. And just as funds can get wiped out in a moment, so can your reputation.
With billions of dollars flowing onchain, hackers are always probing for weak spots; recent stats show the stakes: that $2.3B lost in H1 2025. Most of those losses came from preventable bugs like bad access controls and reentrancy. By prioritizing security, you can protect your users, build trust, and avoid the nightmare of your project going to $0 in a single transaction.
With the tools and best practices on the market, there are plenty of things that you can do that will allow you to write better code that is not only functional, but resilient.
Understanding Smart Contract Vulnerabilities
One helpful resource to understanding smart contract vulnerabilities is OWASP (short for Open Web Application Security Project), which is a nonprofit that creates free resources to improve software security worldwide. Their Smart Contract Top 10 is like a hit list of the most dangerous vulnerabilities in blockchain apps, based on real world exploits and data from incidents causing billions in losses.
This list gives devs a prioritized shortlist of vulnerabilities to focus on in audits and contract design, drawing from patterns in hacks across ecosystems. The 2025 edition highlights risks like access flaws that led to $953M in losses last year alone. When reviewing your code, being conscious about these common vulnerabilities is a great grounding framework to start building up your code’s resiliency.
Access control vulnerabilities: Flaws letting unauthorized folks mess with data or functions, often from missing permission checks. This issue tops the list for a reason. Think stolen admin keys draining contracts. Simple, but effective and prevelant.
Price oracle manipulation: Hackers can tamper with external data feeds (oracles) to skew prices, leading to bad loans or trades. This is common in DeFi where accurate prices are crucial.
Logic errors: Bugs where the contract does something unintended, like minting extra tokens or miscalculating rewards. A vulnerability where you have to cover every edge case. Requires deep auditing of contract logic.
Lack of input validation: Not checking user inputs can allow junk data to break logic or exploit overflows.
Reentrancy attacks: External calls can let hackers re-enter functions mid execution, often to withdraw funds multiple times.
Unchecked external calls: Failing to handle failed calls can cause the contract to proceed with wrong assumptions.
Flash loan attacks: Abusing instant loans can manipulate markets or protocols in a single transaction.
Integer overflow and underflow: Math errors when numbers wrap around limits can lead to wrong calculations or theft.
Insecure randomness: Bad RNG that's predictable can be exploited in games or lotteries.
Denial of service (DoS) attacks: Overloading contracts with gas-hungry operations to make them unusable.
For the full OWASP details, head to their Smart Contract Top 10 page. And for Ethereum specific tips, see their security guidelines.
12 Smart Contract Security Best Practices
Now that you understand the threat landscape, let's dive into practical defenses. Each vulnerability on the OWASP list has corresponding best practices that have been battletested across thousands of contracts.
The following sections break down these common flaws one by one with concrete code examples showing both vulnerable patterns and secure implementations. Think of this as your defensive playbook: straightforward techniques that when applied consistently can reduce your attack surface.
1. Use delegatecall carefully
Delegatecall lets one contract run code from another while using its own storage – super useful for libraries or upgrades, but risky because it can lead to unexpected state changes if the called code messes with your variables. It's behind major exploits where hackers inject malicious logic that overwrites critical state like owner addresses or drains funds.
The danger is that delegatecall preserves the calling contract's context (msg.sender, msg.value, storage), so the external code runs with full privileges. Only use it when absolutely needed, and ensure storage layouts match perfectly between contracts - misaligned slots can corrupt your data.
Here's a vulnerable example:
contract LibraryContract {
address public owner; // Slot 0
function updateOwner() public {
owner = msg.sender; // Changes caller's storage slot 0!
}
}
contract VulnerableProxy {
uint256 public value; // Slot 0 - MISMATCH!
address public owner; // Slot 1
function delegateCall(address lib) public {
// DANGER: updateOwner() will overwrite 'value', not 'owner'!
lib.delegatecall(abi.encodeWithSignature("updateOwner()"));
}
}Here’s a safer approach:
contract SecureProxy {
address public implementation;
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function execute(bytes memory data) public onlyOwner {
(bool success,) = implementation.delegatecall(data);
require(success, "Execution failed");
}
}Best practices: Use established proxy patterns like OpenZeppelin's upgradeable contracts, maintain identical storage layouts between versions (never reorder or change variable types), restrict delegatecall to trusted, audited contracts only, and consider using libraries with library keyword which uses delegatecall safely under the hood and never allow user controlled addresses in delegatecall: that's an instant takeover vector.
2. Use a reentrancy guard
Reentrancy occurs when an external call hands over control before your function completes its state updates, etting the caller jump back in and repeat actions like withdrawals before balances update. It's ranked #5 on OWASP's top Web3 risks and powered some of the most massive hacks in crypto like The Infamous DAO Hack ($60M loss).
Attackers can exploit this reentrancy window between sending funds and updating state to drain contracts by recursively calling withdrawal functions. You should always assume external contracts are hostile.
Vulnerable example:
contract VulnerableBank {
mapping(address => uint256) public balances;
function withdraw() public {
uint256 bal = balances[msg.sender];
require(bal > 0);
// DANGER: Sends Ether before updating balance!
(bool sent,) = msg.sender.call{value: bal}("");
require(sent);
balances[msg.sender] = 0; // Too late - already reentered!
}
}
contract Attacker {
VulnerableBank bank;
fallback() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // Calls withdraw again!
}
}
function attack() external payable {
bank.withdraw(); // Starts the loop
}
}Secure implementation:
solidity
`import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureBank is ReentrancyGuard { mapping(address => uint256) public balances;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() public nonReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0);
// Update state BEFORE external call
balances[msg.sender] = 0;
(bool sent,) = msg.sender.call{value: bal}("");
require(sent);
}
}Best practices: Use OpenZeppelin's ReentrancyGuard modifier, follow Checks-Effects-Interactions pattern (validate → update state → external calls), and consider pull-over-push patterns where users withdraw funds themselves.
3. Use msg.sender instead of tx.origin for authentication
The call tx.origin traces back to the original transaction initiator, while msg.sender refers to the immediate caller; this distinction matters heavily in security. In multi-contract call chains, tx.origin remains constant, but it can be exploited by malicious intermediary contracts that trick your code into thinking the original user is authorized.
An attacker can create a phishing contract that calls your function, and if you check tx.origin, it points to the victim who initiated the transaction, completely bypassing your authentication. msg.sender is always the direct caller, making it reliable for access control. Only use tx.origin in rare cases where you specifically need the transaction originator, and never for authentication. This vulnerability ties directly into access control flaws, OWASP's #1 risk.
Vulnerable example:
contract VulnerableWallet {
address public owner;
constructor() { owner = msg.sender; }
function transfer(address payable to, uint256 amount) public {
require(tx.origin == owner, "Not owner"); // BAD!
to.transfer(amount);
}
}
// Attacker tricks owner into calling this
contract MaliciousContract {
function attack(address wallet) public {
// When owner calls this, tx.origin == owner// So the wallet thinks the call is authorized!
VulnerableWallet(wallet).transfer(payable(msg.sender), 1 ether);
}
}Secure implementation:
contract SecureWallet {
address public owner;
constructor() { owner = msg.sender; }
function transfer(address payable to, uint256 amount) public {
require(msg.sender == owner, "Not owner"); // GOOD!
to.transfer(amount);
}
}Why this matters: If an owner accidentally interacts with a malicious contract (clicking a phishing link, using a compromised app), that contract can drain the vulnerable wallet because tx.origin still points to the owner. With msg.sender, only the owner address itself can authorize transfers. Make sure you always use msg.sender for authentication and access control checks.
4. Properly use Solidity visibility modifiers
Visibility modifiers define access boundaries: public (callable by anyone, internally or externally), external (only from outside the contract), internal (this contract plus inheriting contracts), and private (strictly this contract). In older Solidity versions, omitting a modifier defaulted to public, accidentally exposing sensitive functions to external attackers.
Wrong visibility has led to exploits where hackers called admin functions or manipulated critical state variables that should've been restricted. Always explicitly declare visibility: it's both a security practice and readability win.
solidity
`contract VulnerableBank { mapping(address => uint) balances;
contract VulnerableBank {
mapping(address => uint) balances;
// BAD: accidentally public in old Solidity
function resetBalance(address user) {
balances[user] = 0; // anyone can call this!
}
// GOOD: explicit visibility
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function _updateInternal() internal {
// only this contract + children
}
}Use static analysis tools like Slither to catch missing or incorrect visibility modifiers before deployment.
5. Avoid block timestamp manipulation
Validators can manipulate block.timestamp within a small window (roughly ±15 seconds on Ethereum), making it unreliable for precise timing or as a source of randomness. Miners or validators can adjust timestamps to their advantage, triggering payouts, winning lotteries, or bypassing time based checks.
To that end, never use block.timestamp for critical logic where seconds matter, and absolutely never use it for generating random numbers. It's fine for rough time estimates (like "has 24 hours passed?") but dangerous for exact conditions.
Vulnerable example:
contract TimestampLottery {
uint public lastPlay;
fallback() external payable {
require(msg.value == 10 ether);
require(block.timestamp != lastPlay);
lastPlay = block.timestamp;
// BAD: validator can manipulate timestamp to win
if (block.timestamp % 15 == 0) {
payable(msg.sender).transfer(address(this).balance);
}
}
}Better approach for timing:
contract BlockBasedTiming {
uint public startBlock = block.number;
// Use block numbers instead (~12s per block on Ethereum)
function isTimePassed(uint blocks) public view returns (bool) {
return block.number >= startBlock + blocks;
}
}For randomness, never roll your own. Instead use Chainlink VRF or similar verifiable random function oracles that provide cryptographically secure, tamper proof randomness.
6. Avoid arithmetic overflow and underflow
In Solidity versions before 0.8, integers wrap around silently when they exceed their maximum or minimum values. Meaning a uint8 at 255 increments to 0, or 0 decrements to 255.
This has caused catastrophic exploits where attackers minted infinite tokens, created negative balances that wrapped to huge amounts, or bypassed critical checks.
Vulnerable example (pre-0.8):
pragma solidity 0.7.0;
contract UnsafeToken {
mapping(address => uint8) public balances;
function transfer(address to, uint8 amount) public {
balances[msg.sender] -= amount; // Underflow: 0 - 1 = 255
balances[to] += amount; // Overflow: 255 + 1 = 0
}
}Upgrade to Solidity >=0.8 for automatic overflow protection:
pragma solidity ^0.8.0;
contract SafeToken {
mapping(address => uint8) public balances;
function transfer(address to, uint8 amount) public {
balances[msg.sender] -= amount; // Reverts on underflow
balances[to] += amount; // Reverts on overflow
}
}If stuck on older Solidity, use OpenZeppelin's SafeMath library. For 0.8+, checks are built-in and revert automatically, but you can use unchecked {} blocks when you intentionally want wrapping behavior for gas optimization.
7. Implement robust access control
Broken access control lets unauthorized users execute privileged functions, it's the #1 vulnerability on OWASP's Web3 top 10 and caused over $1.6 billion in losses during the first half of 2025 alone. Without proper guards, attackers can drain funds, mint tokens, pause contracts, or change ownership.
Common mistakes include missing modifiers, relying on tx.origin for auth, or using simple require(msg.sender == owner) checks that get overlooked during upgrades. The fix is role-based access control with principle of least privilege: give each address only the permissions it absolutely needs.
Secure implementation with OpenZeppelin:
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureVault 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 withdrawFunds() public onlyRole(ADMIN_ROLE) {
// Only admins can withdraw
}
function updateConfig() public onlyRole(OPERATOR_ROLE) {
// Operators handle config, not full admin
}
}Regularly audit role assignments, implement time delayed admin actions for high-stakes changes, and use multi-sig wallets for critical roles. Never use tx.origin for authentication. Leverage battle-tested libraries like OpenZeppelin AccessControl rather than rolling your own.
8. Secure oracle integrations against manipulation
Oracles merge blockchain and real world data, but if they're centralized or easily manipulated, attackers can feed false information to exploit your contracts. This type of exploit is ranked #2 on OWASP's Web3 risks and has drained hundreds of millions from DeFi protocols.
Flash loan attacks often manipulate onchain price oracles (like using a single DEX as a price source), letting hackers artificially inflate or crash prices to liquidate positions, drain liquidity pools, or mint undercollateralized loans. Never rely on a single price source or spot prices that can be manipulated within one transaction.
Secure oracle usage with Chainlink:
pragma solidity ^0.8.20;
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract SecurePriceFeed {
AggregatorV3Interface internal priceFeed;
uint256 private constant STALENESS_THRESHOLD = 3600; // 1 hour
constructor(address _aggregator) {
priceFeed = AggregatorV3Interface(_aggregator);
}
function getLatestPrice() public view returns (int) {
(
uint80 roundId,
int price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
require(price > 0, "Invalid price");
require(answeredInRound >= roundId, "Stale price");
require(block.timestamp - updatedAt < STALENESS_THRESHOLD, "Price too old");
return price;
}
}Best practices: use decentralized oracle networks like Chainlink with multiple data sources, implement Time-Weighted Average Prices (TWAPs) for critical operations, validate freshness and sanity-check price bounds, and aggregate multiple oracles when possible. Check Chainlink docs for network-specific feeds and security considerations.
9. Validate all inputs thoroughly
Failing to validate user inputs lets attackers inject malicious data, trigger unexpected behavior, or exploit edge cases in code. It's #4 on OWASP's Web3 vulnerabilities and a common vector for draining funds or breaking contract logic.
Without proper checks, users can pass zero values to bypass fees, negative amounts to exploit arithmetic, addresses pointing to zero or malicious contracts, or array indices that cause out-of-bounds access. Every external input is untrusted until proven safe. Defense in depth means validating at every boundary: check ranges, nulls, array lengths, and business logic constraints before processing any user-supplied data.
Proper input validation:
pragma solidity ^0.8.20;
contract SecureDeposit {
mapping(address => uint256) public balances;
uint256 public constant MAX_DEPOSIT = 1000 ether;
function deposit(uint256 amount) public payable {
require(amount > 0, "Amount must be positive");
require(amount == msg.value, "Amount mismatch");
require(amount <= MAX_DEPOSIT, "Exceeds max deposit");
require(
amount <= type(uint256).max - balances[msg.sender],
"Balance overflow"
);
balances[msg.sender] += amount;
}
function transfer(address to, uint256 amount) public {
require(to != address(0), "Invalid recipient");
require(to != address(this), "Cannot transfer to contract");
require(amount <= balances[msg.sender], "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}}`
Always validate that amounts are non-zero and within bounds, addresses aren't null or invalid, array indices are in range, and business logic constraints are met.
Use custom errors in Solidity 0.8.4+ for gas efficient reverts with detailed messages.
10. Handle external calls safely
External calls to other contracts can fail silently if you don't check their return values. This issue is #6 on OWASP's Web3 risks and has led to funds getting stuck or contracts assuming success when operations actually failed.
Low-level calls like call(), delegatecall(), and send() return boolean success indicators instead of reverting automatically. If you ignore these return values, your contract might continue executing with false assumptions, thinking funds transferred when they didn't, or that a critical operation completed when it failed. ERC-20's transfer() also has this issue in some implementations that return false instead of reverting.
Vulnerable pattern:
function unsafeTransfer(address payable recipient) public {
recipient.send(1 ether); // Returns false on failure, but continues!// Contract thinks transfer succeeded
}Secure implementation:
contract SecureCaller {
event CallExecuted(address target, bool success);
function safeCall(address target, bytes memory data) public returns (bytes memory) {
(bool success, bytes memory result) = target.call(data);
require(success, "External call failed");
emit CallExecuted(target, success);
return result;
}
function safeTransfer(address payable recipient, uint256 amount) public {
(bool success, ) = recipient.call{value: amount}("");
require(success, "Transfer failed");
}
// For ERC-20 tokens, use SafeERC20 from OpenZeppelin
}}`
Always capture and check return values from external calls. For Ether transfers, prefer call{value: x}("") over deprecated send() or transfer(). For token transfers, use OpenZeppelin's SafeERC20 wrapper which handles non standard ERC-20 implementations that don't revert on failure.
11. Mitigate flash loan attacks
Flash loans allow borrowing massive amounts with no collateral as long as you repay within the same transaction. Attackers can exploit this logic to manipulate prices, drain pools, or exploit protocol logic, making it #7 on OWASP's Web3 risks with billions lost across DeFi.
Hackers use flash loaned capital to artificially skew oracle prices, exploit rounding errors at scale, or create temporary market conditions that trigger vulnerable contract logic. A classic pattern that has played out in hacks time and time again: borrow millions, manipulate a price oracle or liquidity pool, exploit the mispricing in your protocol, repay the loan, and pocket the difference – all atomically in one transaction.
Vulnerable pattern:
contract VulnerablePool {
function getPrice() public view returns (uint) {
// BAD: using spot price that can be manipulated
return tokenReserve / ethReserve;
}
function borrow(uint amount) public {
uint collateral = getPrice() * userCollateral[msg.sender];
require(collateral >= amount, "Insufficient collateral");
// Attacker manipulates price in same transaction!
}
}Better approach:
contract SecureLending {
mapping(address => uint256) public lastBorrow;
function borrow(uint256 amount) public {
// Add cooldown between borrows
require(block.number > lastBorrow[msg.sender] + 2, "Too soon");
lastBorrow[msg.sender] = block.number;
// Use TWAP or Chainlink instead of spot price
uint256 price = chainlinkOracle.getPrice();
uint256 collateral = price * userCollateral[msg.sender];
require(collateral * 150 / 100 >= amount, "Need 150% collateral");
}
}Defense strategies: use Time Weighted Average Prices (TWAPs) or Chainlink instead of spot prices. Add multi-block cooldowns for critical operations, require over-collateralization, and verify your protocol's health after state changes. If you don't need flash loans, consider blocking them entirely.
12. Avoid logic errors in critical functions
Logic errors in critical functions break your contract's core security assumptions and can be catastrophic. This category of error is #3 on OWASP's Web3 risks because these flaws undermine the entire system despite being technically "correct" code.
Unlike syntax bugs caught by compilers, logic errors pass all checks but produce wrong outcomes: off-by-one errors in loops, incorrect order of operations, missing edge case handling, or flawed conditions. Classic examples include forgetting to update balances after transfers, using >= instead of >, or calculating fees in the wrong order causing precision loss.
Common logic errors:
contract LogicErrors {
mapping(address => uint256) public balances;
// ERROR 1: Balance updated AFTER transfer (reentrancy risk!)
function withdraw(uint256 amount) public {
payable(msg.sender).transfer(amount);
balances[msg.sender] -= amount; // Too late!
}
// ERROR 2: Off-by-one lets loop access invalid index
function distribute(address[] memory users) public {
for(uint i = 0; i <= users.length; i++) { // Should be // Crashes on last iteration!
}
}
}Corrected version:
contract SecureLogic {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
// Update state BEFORE external call
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function distribute(address[] memory users) public {
for(uint i = 0; i < users.length; i++) { // Correct boundary// Safe iteration
}
}
}Defense strategies: Follow checks-effects-interactions pattern (validate → update state → external calls), write unit tests for edge cases (zero, max values, empty arrays), use fuzzing with Foundry to test random inputs, and define invariants like "total distributed should never exceed pool balance." Always have peer reviews for critical functions.
6 Popular Smart Contract Security Tools
The above best practices can help you increase the resiliency, but you also need tools that can catch errors the eye might miss. Here's a mix of classic and modern tools for auditing your code:
Slither: A static analyzer with 40+ detectors for flaws. Great for quick scans and prints contract details. GitHub.
Mythril: An EVM bytecode analyzer for multiple chains that can spot symbolic issues. Part of MythX. GitHub.
Securify: An Ethereum Foundation backed scanner for 37+ flaws, with precise static analysis. Website.
Foundry: A modern testing/fuzzing framework for Solidity that offers invariant testing that shines for logic bugs. Book.
Certora: A formal verification tool that proves code matches specs mathematically. Website.
Echidna: A property-based fuzzer for vulnerabilities. GitHub.
Along with these 6 tools, you can also partner with platforms like Code4rena for bountied audits.
Secure Your Smart Contracts: Final Thoughts
As a dev, your goal is simple: build contracts that don't get hacked, keeping user funds safe and your app running smooth. In that process, you should adopt the best practices above and embrace the mentality of being secure by design.
Use upgradable proxies (via OpenZeppelin Upgrades), modular code, and account abstraction (like ERC-4337 for better UX/security – see our ERC-4337 guide for more details). Run unit tests, formal verifs, pro audits, runtime monitoring (e.g., via Fortress), and bug bounties on Immunefi. Your users (and your project’s success) depend on it.
For more resources around contract security, check out the following:

Related overviews
What it is, How it Works, and How to Get Started
Explore the Best Free and Paid Courses for Learning Solidity Development
Your Guide to Getting Started With Solidity Arrays—Functions, Declaring, and Troubleshooting

Build blockchain magic
Alchemy combines the most powerful web3 developer products and tools with resources, community and legendary support.