Smart contracts are the rare kind of software where a single bug can drain millions in seconds, with no undo. Contracts are public, immutable, and hold value directly, which means attackers can read your code, and every flaw is a standing invitation. This lesson covers the security mindset and the classic vulnerabilities every developer must know — reentrancy, broken access control, unchecked inputs — plus the defensive patterns and habits that prevent them. You don't need to become an auditor, but you must know these, because on-chain, security isn't a feature; it's the whole job.
- Adopt the mindset that contract code is public, immutable, and adversarial
- Explain the reentrancy attack and the checks-effects-interactions defence
- Recognise access-control and input-validation failures
- Apply defensive habits: audited libraries, testing, and audits before mainnet
The security mindset
Three facts make smart-contract security unlike ordinary software. First, your code is public — attackers read the exact source and bytecode looking for flaws. Second, a deployed contract is usually immutable — you can't quietly patch a bug once it's live. Third, contracts hold value directly, so a flaw isn't a crash, it's a theft. Together these mean you must assume every function will be called by a hostile party, in any order, with any inputs, possibly many times in one transaction. Security here is adversarial by default. The good news is that most real losses come from a small set of well-understood mistakes — learn them and you avoid the majority of danger.
Reentrancy: the classic attack
Reentrancy is the most famous smart-contract vulnerability, and it caused one of the largest early hacks. It happens when your contract sends ether to an external address *before* it updates its own bookkeeping. Sending ether can hand control to the recipient's code, and a malicious recipient can call back *into your function again* before your first call finished — re-entering it while your state still says they're owed money. Done repeatedly in one transaction, they withdraw far more than they had. The vulnerable shape is: check balance, send money, then reduce balance. The attacker strikes in the gap between sending and reducing.
// VULNERABLE: sends ether before updating state
function withdraw() public {
uint256 amount = balanceOf[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}(""); // hands over control!
require(ok, "send failed");
balanceOf[msg.sender] = 0; // too late: attacker re-entered above
}The checks-effects-interactions defence
The fix is a simple, universal ordering rule: checks, then effects, then interactions. First do all your checks (require statements). Then apply all your effects — update your own state, including zeroing the balance. *Only last* do you interact with external addresses (send ether, call another contract). Because your state is already updated before you hand over control, a re-entering call sees the correct, updated state and gets nothing. This one ordering discipline prevents reentrancy. A reusable reentrancy guard modifier (OpenZeppelin's nonReentrant) adds belt-and-braces protection by blocking any nested call into the same function.
// SAFE: update state BEFORE sending (effects before interactions)
function withdraw() public {
uint256 amount = balanceOf[msg.sender];
require(amount > 0, "nothing to withdraw"); // checks
balanceOf[msg.sender] = 0; // effects (state first!)
(bool ok, ) = msg.sender.call{value: amount}(""); // interactions last
require(ok, "send failed");
}Memorise the order: checks → effects → interactions. Update your own bookkeeping before you ever call out to another address. This single habit closes the door on reentrancy, the vulnerability behind some of the biggest losses in the space's history.
Access control and input validation
Two more failure classes cause a huge share of real incidents. Broken access control means a sensitive function isn't properly restricted — a mint function, a withdraw-everything function, or an upgrade function left callable by anyone, or a missing onlyOwner. Because the code is public, attackers specifically hunt for powerful functions lacking a guard. Always ask, for every state-changing function: *who should be allowed to call this, and is that enforced?* Missing input validation is the other: not checking amounts, addresses (the zero address is a classic trap), or conditions, so the contract does something nonsensical or exploitable. Every external input is attacker-controlled and must be validated.
- Restrict privileged functions (mint, withdraw, upgrade) with a modifier like onlyOwner.
- Reject the zero address where an address must be real.
- Validate amounts and ranges — don't trust that inputs are sensible.
- Assume every external caller is hostile and every input is chosen to break you.
Other traps and defensive habits
A few more to keep on your radar. Integer overflow — arithmetic wrapping around — was a common bug, but Solidity 0.8+ reverts on it automatically, so keep your compiler current and be cautious with any unchecked blocks. Oracle and price manipulation: never trust a price you can cheaply move (like a single DEX's spot price) for anything valuable; use robust oracles (a DeFi topic). Relying on secrecy: nothing on-chain is hidden — private variables are still readable, so never store secrets in a contract. The defensive habits that tie it together are steady: prefer audited libraries (OpenZeppelin) over hand-rolled code, test adversarially including the failure and attack cases, keep functions small and permissioned, and — for anything holding real value — get a professional audit before mainnet.
Nothing on a public blockchain is secret. Marking a variable private only stops other contracts from reading it in Solidity — anyone can still read its value directly from the chain's storage. Never store passwords, keys, or answers you need to keep hidden in a contract.