Secure Python
The Python-specific ways programs get compromised and their fixes: secrets instead of random, password hashing, subprocess without a shell, why pickle runs code, path traversal, TLS verification, untrusted XML and archives, and auditing dependencies.
What you will learn
- Generate tokens with secrets and store passwords with a slow, salted hash
- Run other programs without shell injection, and quote correctly when a shell is unavoidable
- Explain how unpickling runs code, and restrict or replace pickle
- Keep user-supplied paths inside a folder
- Keep TLS verification on, and parse untrusted XML and archives safely
- Recognise the other standard-library traps and audit third-party dependencies
On this page
The fundamentals lesson on security basics set out the principles: never trust input, never build commands out of it, keep secrets out of code. This lesson is about where Python specifically makes those mistakes easy, and the standard-library tools that fix them. Every demonstration here is harmless: the “attacks” only print text.
Randomness for secrets
random is a simulation tool. Its generator is designed to be reproducible, and its future output can be predicted from enough past output. Anything an attacker must not guess, such as a reset link, a session ID or an API key, comes from secrets, which reads the operating system’s cryptographic source:
import randomimport secrets
# random is a simulation tool: the same seed gives the same "secret".random.seed(2026)first = random.getrandbits(64)random.seed(2026)print("random repeats itself:", first == random.getrandbits(64))
# secrets reads the operating system's cryptographic source.token = secrets.token_urlsafe(32)print("token_urlsafe(32):", len(token), "characters")print("token_hex(16):", len(secrets.token_hex(16)), "characters")
# Compare secrets in constant time, never with ==.sent = "Z3k9-reset-code"print(secrets.compare_digest(sent, "Z3k9-reset-code"))print(secrets.compare_digest(sent, "Z3k9-reset-codX"))random repeats itself: Truetoken_urlsafe(32): 43 characterstoken_hex(16): 32 charactersTrueFalse32 random bytes is what the secrets documentation considers sufficient for typical use. == on strings stops at the first difference, so its timing leaks how much of a guess was right; compare_digest takes the same time either way.
Passwords
Never store a password, and never store a plain fast hash such as sha256(password) either: an attacker with your database can try billions of guesses per second. Use a deliberately slow, salted key-derivation function. The standard library has hashlib.scrypt:
import hashlibimport osimport secrets
def hash_password(password, salt=None): """Return (salt, digest). A new random salt unless one is given.""" salt = salt or os.urandom(16) digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1) return salt, digest
def check_password(password, salt, digest): _, candidate = hash_password(password, salt) return secrets.compare_digest(candidate, digest)
salt, digest = hash_password("correct horse")print(len(salt), len(digest))print(check_password("correct horse", salt, digest))print(check_password("correct hors", salt, digest))print(hash_password("correct horse")[1] == digest)16 64TrueFalseFalseThe last line shows why the salt matters: the same password hashed twice gives different digests, so one precomputed table cannot crack every account. Store the salt and the cost parameters next to the digest. In a web framework, use its password hasher, which does all this and can raise the cost over time.
Running other programs
subprocess with a list of arguments never involves a shell: each list item reaches the program as exactly one argument, whatever characters it contains. shell=True hands one string to /bin/sh, which interprets ;, |, $( ) and the rest. This demonstration (for Linux and macOS) runs echo three ways with a hostile file name:
import shleximport subprocess
# Imagine this came from a web form. The payload only runs `echo`.name = "report.txt; echo INJECTED: a second command ran"
def show(label, **kwargs): result = subprocess.run(capture_output=True, text=True, **kwargs) print(label) print(" ", result.stdout.rstrip().replace("\n", "\n "))
show("list of arguments, no shell:", args=["echo", name])show("shell=True and an f-string:", args=f"echo {name}", shell=True)show("shell=True and shlex.quote:", args=f"echo {shlex.quote(name)}", shell=True)list of arguments, no shell: report.txt; echo INJECTED: a second command ranshell=True and an f-string: report.txt INJECTED: a second command ranshell=True and shlex.quote: report.txt; echo INJECTED: a second command ranIn the middle case the shell ran a second command chosen by the “user”. Here it was echo; in an attack it is whatever the attacker likes. Use the list form. If you truly need a shell feature, quote every value with shlex.quote, which is correct for POSIX shells only. os.system and os.popen always use a shell, so avoid them for anything that includes input. A value starting with - can still be read as an option by the target program, so put -- before user-supplied file names where the program supports it.
Why pickle runs code
pickle does not store data; it stores instructions for rebuilding objects, and an object chooses those instructions through __reduce__. Any callable will do:
import pickle
class Surprise: def __reduce__(self): # "To rebuild me, call print with this argument." return (print, ("unpickling ran code chosen by the sender",))
data = pickle.dumps(Surprise())print("the payload names a function:", b"print" in data)print("loading it...")result = pickle.loads(data)print("loads returned", result)the payload names a function: Trueloading it...unpickling ran code chosen by the senderloads returned NoneThe receiving program never mentions Surprise. The payload alone decided to call print, and an attacker would choose os.system instead. Hence the warning in the pickle documentation: never unpickle data that could have come from an untrusted source or been tampered with. For data that crosses a trust boundary, use JSON or another format that can only describe data. If you must read pickles, an allow-list Unpickler restricts which globals can be loaded:
import ioimport picklefrom datetime import date
class AllowListUnpickler(pickle.Unpickler): ALLOWED = {("datetime", "date")}
def find_class(self, module, name): if (module, name) in self.ALLOWED: return super().find_class(module, name) raise pickle.UnpicklingError(f"{module}.{name} is not allowed")
def safe_loads(data): return AllowListUnpickler(io.BytesIO(data)).load()find_class is the one place where a pickle turns a name into a real object, so refusing there stops the payload before anything is called:
class Surprise: def __reduce__(self): return (print, ("this must not run",))
print(safe_loads(pickle.dumps({"due": date(2026, 10, 14)})))try: safe_loads(pickle.dumps(Surprise()))except pickle.UnpicklingError as error: print("refused:", error){'due': datetime.date(2026, 10, 14)}refused: builtins.print is not allowedPaths from users
A file name from a request can contain .. or be absolute. The pathlib lesson covered joining and resolving; here the point is refusing. Resolve the joined path and check that it is still inside the folder you meant:
import tempfilefrom pathlib import Path
def safe_join(base, user_path): """Resolve user_path inside base, or refuse.""" base = base.resolve() target = (base / user_path).resolve() if not target.is_relative_to(base): raise ValueError(f"{user_path!r} is outside the upload folder") return target
with tempfile.TemporaryDirectory() as root: uploads = Path(root, "uploads") uploads.mkdir() for request in ["report.txt", "2026/march.csv", "../secrets.env", "/etc/passwd"]: try: target = safe_join(uploads, request) print("allowed:", target.relative_to(uploads.resolve())) except ValueError as error: print("refused:", error)allowed: report.txtallowed: 2026/march.csvrefused: '../secrets.env' is outside the upload folderrefused: '/etc/passwd' is outside the upload folderJoining an absolute path replaces the base entirely, which is why /etc/passwd needs the check as much as .. does. resolve() also follows symbolic links, so a link inside the folder that points outside it is refused too.
TLS, XML and archives
Three kinds of input arrive from outside without ever passing through your own parsing code: a server’s certificate, an XML document and an archive.
TLS. ssl.create_default_context() loads the system’s trusted certificates and checks both the certificate chain and the host name. The usual “fix” for a certificate error switches verification off, which lets anyone between you and the server read and change the traffic:
import ssl
def describe(label, context): print(f"{label:<10}", context.verify_mode.name, "check_hostname =", context.check_hostname)
describe("default", ssl.create_default_context())describe("unverified", ssl._create_unverified_context())
# The usual "fix" for a certificate error, done in the wrong order:context = ssl.create_default_context()try: context.verify_mode = ssl.CERT_NONEexcept ValueError as error: print("ValueError:", error)
# To trust a private CA, add it; do not switch verification off.# context = ssl.create_default_context(cafile="company-ca.pem")default CERT_REQUIRED check_hostname = Trueunverified CERT_NONE check_hostname = FalseValueError: Cannot set verify_mode to CERT_NONE when check_hostname is enabled.Never ship _create_unverified_context, CERT_NONE or a library’s verify=False. For an internal certificate authority, pass its certificate as cafile. Requires version 3.13 or later The default context also sets VERIFY_X509_STRICT, so it rejects some malformed certificates that older versions accepted.
XML. Entity declarations let a short document expand enormously. This “billion laughs” document is 548 bytes:
import pyexpatimport xml.etree.ElementTree as ET
# Ten levels of entities, each one repeating the level below ten times:# a few hundred bytes that would expand to a billion copies of "lol".entities = ['<!ENTITY a0 "lol">']for level in range(1, 10): entities.append(f'<!ENTITY a{level} "{f"&a{level - 1};" * 10}">')BOMB = f"<!DOCTYPE bomb [{''.join(entities)}]><bomb>&a9;</bomb>"
print("input:", len(BOMB), "bytes")print("Expat 2.7.2 or newer:", pyexpat.version_info >= (2, 7, 2))try: ET.fromstring(BOMB)except ET.ParseError as error: print("ParseError:", error)input: 548 bytesExpat 2.7.2 or newer: TrueParseError: limit on input amplification factor (from DTD and entities) breached: line 1, column 537The standard library’s parsers use the Expat library, which refuses this, and does not fetch external files, as long as it is new enough. The XML security section of the docs lists the attacks and says that Expat older than 2.7.2 may be vulnerable; check pyexpat.EXPAT_VERSION, because some Linux builds use the system’s copy. For XML from strangers, the third-party defusedxml package wraps the same parsers and refuses entity declarations outright.
Archives. An archive member can be named ../outside.txt or /etc/cron.d/job, or be a link to /etc/passwd. tarfile’s data filter rewrites or refuses each one:
import ioimport tarfileimport tempfilefrom pathlib import Path
def hostile_archive(): """An archive with one honest member and three escape attempts.""" buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w") as tar: for name in ["notes.txt", "../outside.txt", "/etc/cron.d/job"]: info = tarfile.TarInfo(name) info.size = 2 tar.addfile(info, io.BytesIO(b"hi")) link = tarfile.TarInfo("passwd") link.type, link.linkname = tarfile.SYMTYPE, "/etc/passwd" tar.addfile(link) buffer.seek(0) return bufferwith tempfile.TemporaryDirectory() as folder: with tarfile.open(fileobj=hostile_archive()) as tar: for member in tar.getmembers(): try: tar.extract(member, folder, filter="data") print("extracted:", member.name) except tarfile.FilterError as error: print("refused: ", member.name, type(error).__name__) files = [p for p in Path(folder).rglob("*") if p.is_file()] print(sorted(p.relative_to(folder).as_posix() for p in files))extracted: notes.txtrefused: ../outside.txt OutsideDestinationErrorextracted: /etc/cron.d/jobrefused: passwd AbsoluteLinkError['etc/cron.d/job', 'notes.txt']The absolute name lost its leading slash and landed inside the folder. data is the default from 3.14; pass it explicitly to be safe on older versions. Requires version 3.15 or later What’s New 3.15 lists further fixes, each with a CVE: the filter now normalises link targets, extraction re-applies it when a link is replaced by a copy of another member (refusing with the new LinkFallbackError), and rejected members are no longer extracted when errorlevel is 0. Even so, the tarfile documentation says the filters are not enough for untrusted archives on their own: extract into a new empty folder and limit file count and total size. zipfile strips leading slashes and removes .. components from member names when it extracts, but its documentation gives the same warning: inspect untrusted archives first.
Other traps in the standard library
| Instead of | Use | Why |
|---|---|---|
eval(text) to parse a literal |
ast.literal_eval(text) or json.loads |
eval runs any expression |
assert user.is_admin |
if not ...: raise PermissionError |
python -O removes asserts |
| f-strings in SQL | driver placeholders, or template strings | SQL injection |
tarfile extractall() on 3.13 and older |
extractall(path, filter="data") |
see archives above |
| a regex with nested quantifiers on user input | a simpler pattern and a length limit (regex lesson) | backtracking can take exponential time (ReDoS) |
| remote attaching left on in a process that must not be inspected | -X disable-remote-debug (monitoring and debugging) |
pdb -p and sys.remote_exec run code inside a live process |
tempfile.mktemp() |
NamedTemporaryFile, mkstemp, TemporaryDirectory |
another process can take the name first |
yaml.load with an unsafe loader (PyYAML) |
yaml.safe_load |
unsafe loaders can build arbitrary objects |
Your dependencies
Most of the code you run, you did not write. Pin what you deploy with a lock file that records hashes, as in building and publishing, so a replaced file is refused. Scan the pinned set for known vulnerabilities with an auditing tool such as the PyPA’s pip-audit, in CI, on a schedule, not only when you change dependencies. Check a new dependency’s name letter by letter before installing it, since typo-squatted names are a common attack, and prefer projects that publish with trusted publishing and attestations.
When not to reach for more
Security effort should follow risk. A script that only you run on your own files does not need an allow-list unpickler, and pickle is fine for a cache your own program writes and reads. What is never fine is inventing primitives: do not write your own hashing scheme, encryption, escaping or sanitiser when hashlib, a vetted library or the list form of subprocess exists. And do not “sanitise” input for a shell when you can avoid the shell altogether.
Common pitfalls
- Checking a path with
str.startswith./srv/uploads-oldstarts with/srv/uploads. See the last exercise. - Logging secrets. The token you generated safely ends up in a log file. The logging lesson shows a redacting filter.
- Catching the injection only at the edge. Data stored today is input to tomorrow’s job. Treat values from your own database as untrusted when they reach a shell, SQL or HTML.
This password-reset helper has four security bugs. Name each one and how to fix it, then write a fixed version.
import osimport pickleimport randomimport string
def reset_token(): return "".join(random.choices(string.ascii_letters, k=20))
def notify(email): os.system(f"mail -s 'Password reset' {email} < reset.txt")
def load_session(cookie): return pickle.loads(bytes.fromhex(cookie))
def require_admin(session): assert session["admin"], "admins only"Expected: four findings. A fixed version that replaces the mailer with a child Python process, and is called with a 32-byte token, the address ana@example.com; echo pwned and a JSON session for a non-admin, prints:
43mail to ana@example.com; echo pwnedPermissionError: admins onlyShow solution
random.choicesis predictable: usesecrets.token_urlsafe(32).os.systemwith an f-string lets the address inject shell commands: pass a list tosubprocess.run.pickle.loadson a cookie runs whatever code the client sent: store JSON (and sign it, or keep sessions server-side).assertdisappears underpython -O: raisePermissionErrorexplicitly.
import jsonimport secretsimport subprocessimport sys
MAILER = "import sys; print('mail to', sys.argv[1])"
def reset_token(): return secrets.token_urlsafe(32)
def notify(email): command = [sys.executable, "-c", MAILER, email] result = subprocess.run(command, capture_output=True, text=True, check=True) return result.stdout.strip()def load_session(cookie): return json.loads(cookie)
def require_admin(session): if not session.get("admin"): raise PermissionError("admins only")
print(len(reset_token()))print(notify("ana@example.com; echo pwned"))session = load_session('{"user": "ana", "admin": false}')try: require_admin(session)except PermissionError as error: print("PermissionError:", error)43mail to ana@example.com; echo pwnedPermissionError: admins onlyThe hostile address reached the mailer as one harmless argument. Validating that it looks like an e-mail address is still worth doing, but it is a second layer, not the fix.
In what order are these lines printed? Does init: hello appear once or twice?
import pickle
class Note: def __init__(self, text): print("init:", text) self.text = text
def __reduce__(self): return (print, ("reduce: " + self.text,))
data = pickle.dumps(Note("hello"))print("dumped")copy = pickle.loads(data)print("loaded:", copy)Show solution
init: hellodumpedreduce: helloloaded: None__init__ runs once, when the program creates the note. dumps calls __reduce__ but only records (print, ("reduce: hello",)), so nothing is printed then. loads performs the call, which prints the line and returns None, and None is the “copy”. Unpickling never calls __init__; it calls whatever the payload names.
This safe_join uses string operations. It refuses ../secrets.env but lets a request into a neighbouring folder:
import osimport tempfile
def safe_join(base, user_path): target = os.path.normpath(os.path.join(base, user_path)) if not target.startswith(base): raise ValueError(f"{user_path!r} is outside the upload folder") return target
with tempfile.TemporaryDirectory() as root: uploads = os.path.join(root, "uploads") for request in ["report.txt", "../secrets.env", "../uploads-old/a.db"]: try: target = safe_join(uploads, request) print("allowed:", os.path.relpath(target, root)) except ValueError as error: print("refused:", error)allowed: uploads/report.txtrefused: '../secrets.env' is outside the upload folderallowed: uploads-old/a.dbExplain the hole, and fix safe_join so the output becomes:
allowed: uploads/report.txtrefused: '../secrets.env' is outside the upload folderrefused: '../uploads-old/a.db' is outside the upload folderShow solution
import osimport tempfilefrom pathlib import Path
def safe_join(base, user_path): base = Path(base).resolve() target = (base / user_path).resolve() if not target.is_relative_to(base): raise ValueError(f"{user_path!r} is outside the upload folder") return target
with tempfile.TemporaryDirectory() as root: uploads = os.path.join(root, "uploads") for request in ["report.txt", "../secrets.env", "../uploads-old/a.db"]: try: target = safe_join(uploads, request) print("allowed:", target.relative_to(Path(root).resolve())) except ValueError as error: print("refused:", error)allowed: uploads/report.txtrefused: '../secrets.env' is outside the upload folderrefused: '../uploads-old/a.db' is outside the upload folder"/tmp/x/uploads-old/a.db".startswith("/tmp/x/uploads") is true: a string prefix is not a folder boundary. is_relative_to compares whole path components. resolve() also follows symbolic links, which normpath does not.
Summary
- Tokens come from
secrets; compare them withcompare_digest. Passwords get a salted, slow hash such ashashlib.scrypt. - Pass
subprocessa list. With an unavoidable shell,shlex.quoteevery value. - Unpickling calls whatever the data names. Use JSON across trust boundaries, or an allow-list
Unpickler. - Resolve user paths and check them with
is_relative_to. Preferliteral_eval, explicit checks overassert, and placeholders for SQL. - Keep TLS verification on with
ssl.create_default_context(). Parse untrusted XML with an Expat of 2.7.2 or newer, ordefusedxml. Extract tar files withfilter="data", which 3.15 tightened again. - Lock dependencies with hashes and audit them on a schedule.
- Next: configuration and observability.