Security Basics 🔐
You do not need to be a security expert. You do need to not make the six mistakes that account for most of the damage, and they are all avoidable in one line each.
1. Secrets never go in code
# WRONG, and it is now in your git history forever
API_KEY = "sk-abc123realkeyhere"
import os
# right: from the environment, and it fails loudly if missing
API_KEY = os.environ["OPENAI_API_KEY"]
# or with a default and a clear error
key = os.environ.get("OPENAI_API_KEY")
if not key:
raise SystemExit("Set OPENAI_API_KEY. See README.")
Deleting a key in a later commit does not remove it: it is still in the history, and on every clone. If you push a secret, revoke it immediately, then clean the history if you must. Bots scan public commits within seconds, and cloud keys have produced five-figure bills overnight.
# .gitignore, from the very first commit
.env
*.key
*.pem
secrets.json
.venv/
2. Never store passwords, store hashes
import hashlib
import secrets
# WRONG in three different ways
def terrible(password):
return password # plain text
def bad(password):
return hashlib.md5(password.encode()).hexdigest() # broken, and unsalted
def still_wrong(password):
return hashlib.sha256(password.encode()).hexdigest() # fast, so brute-forceable
# Acceptable with the standard library only:
def hash_password(password, iterations=600_000):
"""PBKDF2 with a random salt. Slow on purpose."""
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
return f"pbkdf2_sha256${iterations}${salt.hex()}${digest.hex()}"
def verify_password(password, stored):
"""Check a password against a stored hash, in constant time."""
algorithm, iterations, salt_hex, digest_hex = stored.split("$")
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode(), bytes.fromhex(salt_hex), int(iterations)
)
return secrets.compare_digest(digest.hex(), digest_hex)
stored = hash_password("swordfish")
algorithm, iterations, salt, digest = stored.split("$")
print(algorithm, iterations, f"salt={len(salt)} hex chars", f"hash={len(digest)} hex chars")
print(verify_password("swordfish", stored))
print(verify_password("Swordfish", stored))
pbkdf2_sha256 600000 salt=32 hex chars hash=64 hex chars
True
False
Three ideas are doing the work there:
- A salt. Random per password, so identical passwords produce different hashes and precomputed rainbow tables are useless.
- Slowness on purpose. 600,000 iterations costs you a few milliseconds and costs an attacker with a stolen database everything.
compare_digest. Comparing with==returns early on the first differing byte, and the timing difference can leak the answer one character at a time. This is a real, practical attack.
argon2-cffi or bcrypt. Argon2 won the Password Hashing Competition and is memory-hard, which resists GPU cracking in a way PBKDF2 does not. The code above is correct and is the best you can do without installing anything; a library is better.
3. Injection: never build commands from input
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT, admin INTEGER)")
conn.execute("INSERT INTO users VALUES ('Guybrush', 0)")
user_input = "' OR '1'='1"
unsafe = conn.execute(f"SELECT * FROM users WHERE name = '{user_input}'").fetchall()
safe = conn.execute("SELECT * FROM users WHERE name = ?", (user_input,)).fetchall()
print("unsafe returned:", len(unsafe), "rows")
print("safe returned: ", len(safe), "rows")
unsafe returned: 1 rows
safe returned: 0 rows
The same rule, in three places:
import subprocess
filename = "report.txt; rm -rf ~"
# WRONG: shell=True with interpolated input runs whatever they typed
# subprocess.run(f"cat {filename}", shell=True)
# right: a list of arguments, no shell involved
result = subprocess.run(["echo", filename], capture_output=True, text=True)
print(result.stdout.strip())
report.txt; rm -rf ~
Passing a list means the operating system runs echo with one argument that
happens to contain a semicolon. There is no shell to interpret it, so there is nothing to
inject into.
4. eval, exec and pickle are code execution
# Never do this with anything a user can influence
user_input = "2 + 2"
# print(eval(user_input)) # fine here, catastrophic with hostile input
# because this also "works":
# eval("__import__('os').system('rm -rf ~')")
# The safe way to evaluate a literal:
import ast
print(ast.literal_eval("[1, 2, {'a': 3}]"))
try:
ast.literal_eval("__import__('os')")
except ValueError as err:
print("refused:", type(err).__name__)
[1, 2, {'a': 3}]
refused: ValueError
Unpickling data executes code contained in it. Loading a pickle from an untrusted source is equivalent to running a script from that source. The official documentation says so in a red box. Use JSON for anything crossing a trust boundary, and reserve pickle for data your own program wrote and only your own program reads.
import json
# JSON can only describe data, so parsing it can never execute anything
print(json.loads('{"name": "Guybrush", "insults": 8}'))
try:
json.loads("__import__('os')")
except json.JSONDecodeError:
print("JSON refuses to parse code. That is the feature.")
{'name': 'Guybrush', 'insults': 8}
JSON refuses to parse code. That is the feature.
5. Randomness: the right module matters
import random
import secrets
random.seed(42)
print("predictable:", random.randint(1000, 9999))
random.seed(42)
print("predictable:", random.randint(1000, 9999)) # same again
print("token:", len(secrets.token_urlsafe(32)))
print("choice from a set:", secrets.choice(["a", "b", "c"]) in {"a", "b", "c"})
predictable: 2824
predictable: 2824
token: 43
choice from a set: True
random is a Mersenne Twister: excellent statistically, and completely
predictable once you have seen enough output. Its own documentation says it must not be
used for security. Password reset tokens, session ids, API keys and one-time codes all
need secrets.
6. Your dependencies are your attack surface
# check what you have installed against known vulnerabilities
$ pip install pip-audit
$ pip-audit
Found 2 known vulnerabilities in 1 package
Name Version ID Fix Versions
------- -------- ------------------- ------------
requests 2.19.1 GHSA-x84v-xcm2-53pg 2.31.0
- Pin versions so an upgrade cannot happen without you noticing.
- Audit regularly.
pip-auditis free and takes seconds; GitHub's Dependabot does it automatically on a repository. - Check names character by character. Typosquatted packages on PyPI are a real and ongoing attack.
- Fewer dependencies is fewer risks. The standard library is the safest dependency you have.
You will notice none of this required cryptography knowledge. Six habits: secrets in the environment, hashes not passwords, parameters not string building, JSON not pickle, secrets not random, and audited dependencies.
That is not everything. It is the part that accounts for most of the damage done to small projects, and every one of them is a single line of difference.
The checklist
- No secrets in code, and
.envin.gitignorefrom commit one - Passwords hashed with argon2, bcrypt or PBKDF2, never stored or reversibly encrypted
- Parameterised queries everywhere; no SQL built with f-strings
subprocesswith a list, nevershell=Truewith user input- No
eval,execorpickleon untrusted data secrets, notrandom, for anything security-related- Template escaping left on; no HTML built from user input by hand
- Dependencies pinned and audited
- Errors logged, not shown to users with a full traceback
- HTTPS everywhere, and certificate verification never disabled
Audit this login code
Find five problems.
import hashlib
USERS = {"guybrush": "5f4dcc3b5aa765d61d8327deb882cf99"}
def login(username, password):
hashed = hashlib.md5(password.encode()).hexdigest()
if username in USERS and USERS[username] == hashed:
return True
print(f"Failed login for {username} with password {password}")
return FalseReveal solution
- MD5. Cryptographically broken, and far too fast for password hashing regardless.
- No salt. That hash is the well-known MD5 of 'password'; identical passwords produce identical hashes across every user and every site.
- The password is logged in plain text on failure. Now your log file is a credential dump, and users type their real password into the wrong box constantly.
==for hash comparison is not constant time. Usesecrets.compare_digest.- The error distinguishes cases if you extend it: 'no such user' versus 'wrong password' tells an attacker which usernames exist. Say 'invalid username or password' for both.
Bonus: no rate limiting, so an attacker may try as fast as the network allows.
Make the config loader safe
This reads a config file. Make it safe against hostile input while keeping the feature.
def load_config(path):
with open(path) as f:
return eval(f.read())Reveal solution
import ast
import json
from pathlib import Path
def load_config(path):
"""Load a config file. JSON first, then a Python literal, never eval."""
text = Path(path).read_text(encoding="utf-8")
try:
return json.loads(text)
except json.JSONDecodeError:
pass
try:
return ast.literal_eval(text) # data only: no calls, no imports
except (ValueError, SyntaxError) as err:
raise ValueError(f"{path} is not valid JSON or a Python literal") from err
Path("config.json").write_text('{"ship": "Sea Monkey", "crew": 12}', encoding="utf-8")
Path("config.py").write_text("{'ship': 'Sea Monkey', 'crew': 12}", encoding="utf-8")
print(load_config("config.json"))
print(load_config("config.py"))
Path("evil.py").write_text("__import__('os').getcwd()", encoding="utf-8")
try:
load_config("evil.py")
except ValueError as err:
print("refused:", err)
{'ship': 'Sea Monkey', 'crew': 12}
{'ship': 'Sea Monkey', 'crew': 12}
refused: evil.py is not valid JSON or a Python literalast.literal_eval parses the same syntax but only permits literals: strings, numbers, tuples, lists, dicts, sets, booleans and None. There is no way to express a function call, so there is nothing to exploit.
Threat model a small app
You built a web app where users upload a CSV and get a chart back. List what could go wrong.
Reveal solution
- A 10GB upload fills the disk. Limit the size before reading.
- A zip bomb or a CSV with a billion columns exhausts memory. Stream, and cap rows and columns.
- A filename like
../../etc/passwdescapes your upload folder. Never trust an uploaded filename: generate your own. - CSV injection. A cell starting with
=becomes a formula when the output is opened in Excel, and can exfiltrate data. Prefix suspicious cells with an apostrophe on export. - Slow requests as a denial of service. One user uploading large files repeatedly starves everyone. Rate limit, and process out of band.
- Uploaded data left on disk containing someone's personal information. Delete it, and know your retention obligations.
- Errors leaking tracebacks with file paths and library versions. Log the detail, show the user a reference number.
This exercise is threat modelling, and it is mostly just asking 'what does the worst possible user do here' for each input. Doing it on paper for ten minutes finds more real issues than any scanner.
Automation, HTTP, scraping, web apps, databases, data, charts, games, desktop apps, packaging, performance and security. You have now seen the whole landscape of what Python is used for. Level 6 builds one thing properly with all of it: your own AI assistant.