What you'll learn
- Recognize the handful of mistakes behind most Python security incidents
- Avoid injection: never build shell commands or SQL from untrusted text
- Never unpickle,
eval, orexecuntrusted data, and know the safe alternatives - Store passwords with a real hash, keep secrets out of code, and audit dependencies
Concept
Most security incidents in Python code are not exotic. They come from a short list of mistakes that are easy to make and easy to avoid once named: passing untrusted text into a shell or a query, deserializing data you did not create, storing passwords badly, leaking secrets into repositories and logs, and running dependencies with known vulnerabilities. This lesson is that list, with the fix for each. Treat it as a checklist to run against every project before it ships.

Injection is the family of bugs where data becomes code. subprocess.run(f"convert {filename}", shell=True) lets a filename containing ; rm -rf ~ run a second command; the list form with shell=False, which you learned in the automation lesson, passes the filename as one argument and nothing is interpreted. SQL built with f-strings lets ' OR '1'='1 rewrite a query; parameters with ? send values separately, as the database lesson showed. Paths from users can contain ../ to climb out of the folder you meant; resolve them and check they still start with the allowed root.
| Never | Instead |
|---|---|
subprocess.run(cmd_string, shell=True) with input |
subprocess.run([prog, arg1, arg2]) |
f"SELECT ... '{value}'" |
execute("SELECT ... ?", (value,)) |
pickle.loads(data_from_outside) |
json.loads, or ast.literal_eval for Python literals |
eval(text) / exec(text) |
a parser, ast.literal_eval, a dispatch dictionary |
hashlib.md5(password) or plaintext |
hashlib.scrypt / argon2 with a per-user salt |
open(base + user_path) |
(base / user_path).resolve() and check is_relative_to(base) |
random for tokens or salts |
secrets.token_urlsafe() |
verify=False on HTTPS |
fix the certificate; never disable verification |
Deserialization is the other data-becomes-code path. pickle can execute arbitrary code while loading, by design, so pickle.loads on anything that did not come from your own trusted process is remote code execution. The same is true of eval and exec on any text you did not write. Use json for data exchange, ast.literal_eval when you must parse a Python literal, and a dictionary mapping names to functions when input selects behavior. The YAML library's safe_load exists for the same reason.
Passwords are never stored, only their hashes, computed with a slow, salted algorithm designed for the job: hashlib.scrypt from the standard library, or argon2 or bcrypt from PyPI. Fast hashes like MD5 and SHA-256 are for integrity, not passwords, because attackers can try billions per second. Compare hashes with hmac.compare_digest to avoid timing leaks, and generate tokens and salts with secrets, never random. Secrets themselves follow the configuration lesson: environment, never code, never logs, rotated if exposed. Finally, dependencies carry vulnerabilities of their own; run pip-audit against your exported lockfile in CI, pin versions, and update deliberately.

Code
Shell injection, shown and prevented. The shell=True string form lets a crafted filename run a second command; the list form treats it as one harmless argument.
# shell_injection.py
import subprocess
import sys
from pathlib import Path
Path("victim.txt").write_text("important\n")
filename = "report.txt; echo INJECTED > victim.txt"
subprocess.run(f"echo processing {filename}", shell=True, capture_output=True)
print("after shell=True:", Path("victim.txt").read_text().strip())
Path("victim.txt").write_text("important\n")
result = subprocess.run([sys.executable, "-c", "import sys; print('processing', sys.argv[1])", filename],
capture_output=True, text=True)
print("after list form:", Path("victim.txt").read_text().strip(), "|", result.stdout.strip()[:30] + "...")
after shell=True: INJECTED
after list form: important | processing report.txt; echo IN...Pickle and eval on untrusted data, demonstrated safely with a payload that only prints, and the alternatives that cannot run code.
# deserialize.py
import ast
import json
import pickle
class Payload:
def __reduce__(self):
return (print, ("!!! code ran during unpickling",))
hostile_bytes = pickle.dumps(Payload())
pickle.loads(hostile_bytes)
text_from_user = "__import__('os').getcwd()"
try:
ast.literal_eval(text_from_user)
except ValueError as err:
print("literal_eval refused:", str(err)[:30])
print(ast.literal_eval("{'items': [1, 2], 'ok': True}"))
print(json.loads('{"items": [1, 2], "ok": true}'))
ACTIONS = {"start": lambda: "starting", "stop": lambda: "stopping"}
choice = "stop"
print(ACTIONS.get(choice, lambda: "unknown action")())
!!! code ran during unpickling
literal_eval refused: malformed node or string on li
{'items': [1, 2], 'ok': True}
{'items': [1, 2], 'ok': True}
stoppingPassword hashing done properly: a per-user salt, scrypt, constant-time comparison, and a token from secrets. Two users with the same password get different hashes.
# passwords.py
import hashlib
import hmac
import secrets
def hash_password(password):
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
return salt + digest
def verify(password, stored):
salt, digest = stored[:16], stored[16:]
candidate = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
return hmac.compare_digest(candidate, digest)
a = hash_password("correct horse")
b = hash_password("correct horse")
print(a != b, len(a))
print(verify("correct horse", a), verify("wrong", a))
print(len(secrets.token_urlsafe(32)) >= 40, secrets.token_hex(8) != secrets.token_hex(8))
True 80
True False
True TruePath traversal blocked, and a dependency audit as a CI step. The resolved path must stay inside the allowed folder; pip-audit is shown as the command a pipeline runs.
# paths.py
from pathlib import Path
base = Path("uploads").resolve()
base.mkdir(exist_ok=True)
(base / "report.pdf").write_text("pdf")
def safe_open(user_path):
target = (base / user_path).resolve()
if not target.is_relative_to(base):
raise PermissionError(f"refused: {user_path!r} escapes {base.name}/")
return target.read_text()
print(safe_open("report.pdf"))
for attempt in ("../paths.py", "../../etc/passwd", "sub/../../paths.py"):
try:
safe_open(attempt)
except PermissionError as err:
print(err)
pdf
refused: '../paths.py' escapes uploads/
refused: '../../etc/passwd' escapes uploads/
refused: 'sub/../../paths.py' escapes uploads/# audit.sh
uv pip install pip-audit
pip-audit -r requirements.txt # or: uv export --frozen | pip-audit -r /dev/stdin
# in CI: fail the job if any known vulnerability is reported, and update the pinned version
Try it yourself
Audit the expense project against the table in this lesson: search for shell=True, f-strings near execute, pickle, eval, exec, md5, and random used for anything secret, and fix each finding. Add a safe_path helper for the CSV input path that refuses to leave the configured folder, with a test for ../. Add a user table with scrypt-hashed passwords to the sqlite lesson's code, and write a verify function with a constant-time comparison. Run pip-audit on the lockfile export.
Common mistakes
- Trusting input that "comes from our own frontend". Anything that crosses a process boundary is untrusted; the frontend can be replaced by
curl. - Hashing passwords with SHA-256. It is fast by design, which is exactly wrong for passwords; use scrypt, argon2, or bcrypt with a salt.
- Disabling TLS verification to make an error go away.
verify=Falseremoves the protection entirely; fix the certificate chain instead.
What's next
- Injection is data becoming code: use argument lists for subprocess, parameters for SQL, and path checks for filenames.
- Never unpickle,
eval, orexecuntrusted data; usejson,ast.literal_eval, and dispatch dictionaries. - Hash passwords with scrypt and a salt, compare in constant time, use
secretsfor tokens, keep secrets in the environment, and audit dependencies in CI.
Your code is defensible. Next you will practice the skill every production developer uses daily and few are taught: reading an unfamiliar codebase quickly.