Expert · Lesson 88 of 94

Security essentials

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

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.

Security essentials: untrusted input: users, files, network → subprocess list form, never shell=True with input → parameters (?), never f-strings → never; use json or ast.literal_eval → resolve and check it stays inside the allowed folder
Security essentials · Figure 1 · Open for full size

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.

Security essentials: password → salt = secrets.token_bytes(16) → hashlib.scrypt(password, salt, n=2**14, r=8, p=1) → store salt + hash → login attempt → recompute with the stored salt → hmac.compare_digest(stored, computed)
Security essentials · Figure 2 · Open for full size

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] + "...")
Output
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")())
Output
!!! code ran during unpickling
literal_eval refused: malformed node or string on li
{'items': [1, 2], 'ok': True}
{'items': [1, 2], 'ok': True}
stopping

Password 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))
Output
True 80
True False
True True

Path 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)
Output
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

What's next

Your code is defensible. Next you will practice the skill every production developer uses daily and few are taught: reading an unfamiliar codebase quickly.

Course overview