Secure coding
Injection, escaping, secrets and passwords: the mistakes attackers look for.
Create a free account Try part of it here
Why it matters
Attackers don't need your source code. They send strange input and watch what breaks. The same small set of mistakes causes most real breaches: pasting input into commands, showing user text unescaped, storing passwords badly, leaking keys. AI apps add new doors, because models read untrusted text and can call tools. The habits in this lesson close the old doors, and they're the base for defending the new ones.
What you'll be able to do
- Treat every input as hostile, and spot injection in SQL, file paths, shell commands and HTML
- Escape output so user text can't become code in a web page
- Store passwords with a slow, salted hash and check them in constant time
- Keep secrets out of code, logs and git, and apply least privilege
What it covers
- Think like an attacker
- Escape output: cross-site scripting
- Passwords: slow, salted hashes
- PBKDF2, built from HMAC
- Secrets, randomness and least privilege
Think like an attacker
A building's front desk treats everyone coming in from the street differently from people already inside. A trust boundary is that front desk in your code: any place where data crosses from outside your control to inside it. A form field, a URL, an uploaded file, an API response, an email, a model's output. On the outside of that line, assume everything is hostile. That doesn't mean your users are bad. It means that if one attacker can send it, your code must handle it.
Most attacks are injection: data gets treated as code. You saw SQL injection last lesson. The same mistake has other forms.
Path traversal
Picture a clerk told "fetch the file from two cabinets back, then the boss's drawer", who does as asked. A download feature reads files/<name>. An attacker asks for ../../etc/passwd. Each .. climbs one folder up, out of the folder you meant to share. The fix: work out the real, final path, then check it's still inside the allowed folder.
from pathlib import Path root = Path("/srv/uploads").resolve() def is_safe(name): target = (root / name).resolve() # resolve follows .. and gives the final path return target.is_relative_to(root) for name in ["report.pdf", "2026/q3.csv", "../../etc/passwd", "/etc/passwd"]: print(f"{name!r:22} safe: {is_safe(name)}")
Notice /etc/passwd. Joining an absolute path onto another path throws the first one away entirely. That's a classic surprise, and checking the resolved result catches it.
Shell command injection
Never build a shell command by pasting in user text. A filename like photo.jpg; rm -rf ~ runs a second command. In Python, pass the program and its arguments as a list, which never goes through a shell:
subprocess.run(["convert", filename, "out.png"]) # safe: filename is one argument subprocess.run(f"convert {filename} out.png", shell=True) # unsafe: the shell reads it as code
In plain words: whatever comes from outside is data. Never let it be read as instructions.
Most attacks are the same mistake: data being treated as code.
Show the answer
../config/secrets.env
The .. climbs out of the shared folder. Spaces and accented letters are fine; what matters is where the resolved path ends up.
Escape output: cross-site scripting
Think of a community noticeboard. If a note could run down the street and pick visitors' pockets, you'd want it behind glass. If a web page shows a comment exactly as typed, a commenter can type HTML. A <script> tag then runs in every visitor's browser, where it can steal their session. That's cross-site scripting (XSS), injection into a web page.
The fix is escaping, the glass: turning the characters that mean something in HTML into harmless codes, so the browser shows them as text:
import html comment = '<script>steal(document.cookie)</script> Great tent!' print(html.escape(comment)) print(html.escape('He said "5 < 6" & left'))
Escape at the moment you put text into HTML, every time. Web frameworks and template engines do this automatically. The danger comes when you build HTML strings yourself, or switch the automatic escaping off. Model output counts as user input here. A model that summarized a hostile web page can repeat a script tag word for word.
Passwords: slow, salted hashes
Never store passwords as they were typed. When (not if) a database leaks, every user's password leaks with it, and people reuse passwords everywhere.
Store a hash instead: a one-way function that turns a password into a fixed-size scramble. You can turn a cow into a burger, and never a burger back into a cow. To check a login, hash what was typed and compare. Two more details make it safe:
- Salt. Add a random value, the salt, to each password before hashing, and store it with the hash. Then two users with the password "summer2026" get different hashes. An attacker can't use a precomputed table of common passwords.
- Slow on purpose. A normal hash such as SHA-256 is built to be fast: billions per second on a graphics card. That's terrible for passwords, because attackers can guess quickly. Picture a lock that takes a second to try each key. You won't notice, and a burglar with a million keys gives up. Password hashes repeat the work many thousands of times, so each guess is expensive.
PBKDF2 repeats an HMAC (a keyed hash) thousands of times. The standard algorithms are Argon2, bcrypt, scrypt and PBKDF2. In real code use a library, such as argon2-cffi, bcrypt or hashlib.pbkdf2_hmac, with current settings: about 600,000 rounds for PBKDF2-SHA256. The academy's browser Python doesn't include those, so the example builds PBKDF2 from hmac with fewer rounds, purely to show how it works.
Finally, compare hashes with hmac.compare_digest, not ==. An ordinary comparison stops at the first different character, so its timing leaks how much of a guess was right. compare_digest always takes the same time.
PBKDF2, built from HMAC
This matches the published test value for PBKDF2-HMAC-SHA256 with password "password" and salt "salt".
import hmac, hashlib def pbkdf2_sha256(password: bytes, salt: bytes, iterations: int) -> bytes: mac = hmac.new(password, digestmod=hashlib.sha256) def prf(data): m = mac.copy() m.update(data) return m.digest() u = prf(salt + b"\x00\x00\x00\x01") acc = int.from_bytes(u, "big") for _ in range(iterations - 1): u = prf(u) acc ^= int.from_bytes(u, "big") return acc.to_bytes(32, "big") print(pbkdf2_sha256(b"password", b"salt", 1).hex()) print(pbkdf2_sha256(b"password", b"salt", 4096).hex())
Each round feeds the last result back through the HMAC and folds it in with XOR (^). The first line should start 120fb6cf and the second c5e478d5, the published values. That's how you check crypto code: against official test vectors, never by eye. Even so, use a vetted library in production. Getting crypto subtly wrong is easy and silent.
Show the answer
Different hashes, because each has its own random salt
The salt makes every stored hash unique, which defeats precomputed tables and hides reuse.
Secrets, randomness and least privilege
Secrets are API keys, database passwords and signing keys. Treat them like house keys, and don't leave them under the mat.
- Keep them out of code and out of git. Read them from environment variables or a secret manager. Leaked keys on GitHub are found by bots within minutes.
- Keep them out of logs and error messages. Log "key ending in ...a9F2" if you must log anything.
- Rotate (replace) any key that may have leaked, at once. Deleting the commit isn't enough, because git history and copies remain.
Randomness for security comes from the secrets module: secrets.token_urlsafe(32) for reset links, secrets.token_bytes(16) for salts. The random module is predictable by design, so it's fine for games and shuffling, never for tokens.
Least privilege means giving each part only the access it needs. A cleaner gets keys to the offices, not to the safe. A reporting script gets a read-only database user. An AI agent that drafts emails gets no "send" tool. If the part is compromised or tricked, the damage stays small.
Dependencies are code you didn't write but run anyway. Pin versions, update them, and check for known flaws with tools like pip-audit. Watch for typosquatting, fake packages named one letter off a popular one, such as reqeusts.
The OWASP Top 10 is the industry's list of the most common web security risks. Read it once a year: injection, broken access control and misconfiguration are always near the top.
secrets.token_hex(16) makes 16 random bytes, written as hex. What does this print?import secrets token = secrets.token_hex(16) print(len(token))
Show the answer
32
Each byte becomes two hex characters, so 16 bytes make 32. Use secrets, not random, for anything an attacker must not guess.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
Why are fast hashes like SHA-256 bad for storing passwords?