Python security practices: refuse pickle, list args, autoescape

A closed pickle set aside and an allowlist funnel.

Python security practice is a current interpreter, a lockfile you audit, and no shell=True on user input.

When a version loses upstream patches, every new engine CVE is yours. HeroDevs and others will sell extended support. That is a budget decision. It is not a default.

The usual mistake is a 3.10 app that ‘still works’ and a requirements.txt that was last frozen for a demo.

This page is the practices that still hold on a current Python, and the calendar you should know for the version you run.

Python 3.10 loses upstream patches on 31 October 2026. PEP 619 and the HeroDevs 3.10 note: after that date the PSF will not ship a 3.10 security fix.The 2026 misses are still pickle.loads, a subprocess string with shell=True, a Jinja2 Environment that never set autoescape, and random used to mint a reset token.

Keep the secure coding checklist next to this page. Read injection when the interpreter is SQL or the shell, XSS when the sink is HTML, and Flask locks when the app is Pallets. This page is the four hatches that survive a modern CPython install.

3.10 dies in October. Stay on 3.12 or 3.13

As of 22 August 2026, release PEPs. Dates I can cite:

The 3.13 pickle page still says a crafted stream can run code during load. JSON plus a typed object is the path that stays data.

SecureCoding

LineFirst finalUpstream security until
3.104 Oct 202131 Oct 2026
3.122 Oct 2023Oct 2028
3.137 Oct 2024Oct 2029
3.147 Oct 2025Oct 2030

PEP 719 lists 3.13.14 on 4 August 2026. PEP 745 lists 3.14.7 on 5 August 2026. 3.13 and later get two years of bugfix, then three years of source-only security. 3.12 already finished that bugfix window. It still gets security patches through October 2028. 3.14 is current. This page still names 3.12 and 3.13 because that is the pair most production apps sit on in 2026, and both still have the same four hatches.

A distro package that pins 3.10 past October is not a PSF patch. Ubuntu Pro and commercial backports are a separate contract. If you cannot name the backport vendor, treat 3.10 as done and move the venv to a 3.12.x or 3.13.x interpreter this quarter.

pickle.loads is the hatch the banner names

3.13 pickle page the same day. The warning is still the first thing after the title: the module is not secure, and you only unpickle data you trust. The next sentence says a crafted stream can run code during load. That is CWE-502. The 3.12 docs print the same banner. A new interpreter did not close the hatch.

The control is a format that cannot carry a class. JSON is the one the pickle page names. Use it for cache values, queue bodies, and anything that crossed a socket. Sign the blob with HMAC if you need tamper evidence. Do not unpickle and then hope a allowlist of classes will hold. find_class overrides fail in review because the next vendor pickle still calls loads.

import json
from pathlib import Path

def load_cache(path: Path) -> dict:
 raw = path.read_text(encoding="utf-8")
 data = json.loads(raw)
 if not isinstance(data, dict):
 raise ValueError("cache must be an object")
 return data

def save_cache(path: Path, data: dict) -> None:
 path.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")

load_cache and save_cache are the named pair. A file, a Redis string, or a celery result that used to be a pickle becomes text you can print in a ticket. The miss is the one-liner people still paste:

# BAD: do not ship
# obj = pickle.loads(redis.get(key))

PyYAML is the sibling. The move that matches the banner mpyne named is yaml.safe_load, or yaml.load(stream, Loader=yaml.SafeLoader). Leave yaml.FullLoader and the bare yaml.load(stream) out of the app. Marshal is for .pyc files. Do not use it as a network codec. A job queue that still pickles a traceback or a requests Session is the same hatch with a library name on it. Replace that result backend with JSON, or with a type the worker constructs from fields you listed.

subprocess wants a list, not a shell string

CWE-78 is untrusted text reaching a shell. CPython’s subprocess security section says the library will not pick a shell for you, so metacharacters stay characters when args is a sequence. The hatch is shell=True, or a string you interpolate and then hand to Popen.

3.12 changed one Windows case. that same page: when shell=True, the search order no longer prefers the current directory and %PATH% for cmd.exe. It uses %COMSPEC% and %SystemRoot%\System32\cmd.exe. Dropping a lookalike cmd.exe next to the app no longer wins. That is a 3.12 belt. It is not a reason to keep shell=True on a user filename.

import subprocess
from pathlib import Path

CONVERT = Path("/usr/bin/convert")

def run_convert(src: Path, dest: Path) -> None:
 if not src.is_file():
 raise ValueError("missing source")
 subprocess.run(
 [str(CONVERT), str(src), str(dest)],
 check=True,
 capture_output=True,
 )

run_convert is the named helper. The binary is a path you wrote. The user values are extra list items, not a format string. shell stays off. A leading-dash filename is still a flag problem for some tools. Put -- before user paths when the child documents that convention.

# BAD: do not ship
# subprocess.run(f"convert {src} {dest}", shell=True)

os.system and os.popen are the same hatch with worse names. Grep them. shlex.split on a user string, then run, still lets the user pick the binary. Allowlist the executable. The injection guide on this site is the longer argument for -- and for identifiers you cannot bind. subprocess.run(..., text=True) only changes decoding. It does not make a string safer. capture_output=True keeps the child stdout off your logs until you decide to write it. Log the return code and a truncated stderr, not a command line you interpolated.

Jinja2 still defaults autoescape off

stable Jinja2 autoescaping page. jinja2.Template still defaults autoescape=False. The docs say autoescaping is not enabled by default, and you should set a sensible default. Flask turns it on for .html when you call render_template. A raw Environment() in a worker or a CLI renderer does not.

from jinja2 import Environment, FileSystemLoader, select_autoescape

def make_env(root: str) -> Environment:
 return Environment(
 loader=FileSystemLoader(root),
 autoescape=select_autoescape(enabled_extensions=("html", "htm", "xml")),
 )

make_env is the named factory. select_autoescape turns escape on for those suffixes and, by its own default, for templates built from a string. {{ bio }} then prints text. The hatch names are |safe, Markup(...) on request data, and {% autoescape false %}.

<!-- BAD: do not ship -->
<p>{{ bio|safe }}</p>

<!-- FIX -->
<p>{{ bio }}</p>

Quote every attribute. Jinja will not save an unquoted value={{ name }}. Do not render a template string the user wrote. That is SSTI, which is code execution, and autoescape will not save you. Keep templates on disk. Flask’s |safe note on this site is the same hatch inside Pallets. A mail body built with Template(user_subject) is the string path the docs warn about: autoescape on jinja2.Template defaults false unless you pass it. Use make_env even for mail if the body is HTML.

secrets, not random, for tokens

The 3.13 secrets page is the first-party token API. The module shipped in 3.6. The first paragraph still says use it instead of random, because random is for simulation. DEFAULT_ENTROPY is the length when you omit nbytes. The page says 32 bytes was the 2015 belief for a typical token. I pass 32 on purpose so a future default change does not shrink a reset link I already documented.

import hmac
import secrets
from hashlib import sha256

def mint_reset_token() -> str:
 return secrets.token_urlsafe(32)

def token_matches(stored_hash: str, presented: str) -> bool:
 digest = sha256(presented.encode("utf-8")).hexdigest()
 return hmac.compare_digest(stored_hash, digest)

mint_reset_token and token_matches are the named pair. Store a hash, not the raw token. Compare with hmac.compare_digest or secrets.compare_digest. Do not use random.choice, uuid4 as a secret, or time.time() in a reset URL. uuid4 is fine as an id. It is not a capability.

Django and Flask both tell you to generate SECRET_KEY with this module. The command the Flask config page prints is the one I want in a runbook:

python -c 'import secrets; print(secrets.token_hex(32))'

Put that value in the environment. A literal in git is the miss, including a tutorial string. Password storage is a hasher, not secrets. This page will not invent a bcrypt wrapper. Use the framework hasher.

Prove the four hatches

You are not walking an exploit. You are proving the tree has no loader, no shell string, no raw Jinja2, and no random token.

rg -n "pickle\\.loads|pickle\\.load\\(|yaml\\.load\\(|shell\\s*=\\s*True|os\\.system|os\\.popen|\\|safe|Markup\\(|autoescape false|random\\.(choice|randint)|token_hex\\(\\s*\\)" \
 --glob '!venv' --glob '!.venv' --glob '!__pycache__'

A hit on pickle.loads or yaml.load( is a review. A hit on shell=True needs a human to say the line contains no user text and why a list could not replace it. A hit on |safe or Markup( needs a name for the sanitizer that produced the HTML. A hit on random.choice next to auth is a swap to secrets.

python --version
# Expect: 3.12.x or 3.13.x, not 3.10.x after 31 Oct 2026

Bandit flags pickle and subprocess with shell=True. If CI already runs it, keep those two rules on. If it does not, the grep above is the five-minute proof. A pre-commit hook that only lints style will not see a new pickle.loads in a worker. Put the rg line in the same job that runs tests.

For a cache you already wrote with pickle, do not “just sign it” and keep the loader. Add a version byte, write JSON beside the old key, read JSON first, and delete the pickle key after one TTL. The point is the loader goes away, not that HMAC makes REDUCE safe.

Questions we keep getting

Is pickle safe if I HMAC the blob first?

HMAC proves the writer held the key. It does not make the loader a data parser. If that key leaks, the stream is code again. Prefer JSON. If you must keep pickle for a private file on disk that never leaves the host, treat a leaked key as code execution and rotate the host.

Does shlex.quote make shell=True acceptable?

The 3.13 docs mention shlex.quote when you already invoked a shell. A list with shell=False does not need that quote. Use the list. Quote is leftover work for a pipeline you have not split yet.

Flask already autoescapes. Do I still set select_autoescape?

Yes, in every Environment you build yourself. Flask’s render_template is one path. A mail renderer, a PDF template, or a worker that imports Jinja2 directly is another. make_env is the factory this page uses for those.

Michael Hollander

Michael Hollander / About Author

Michael is a Senior Product Manager and the Data Protection Officer at WhiteSource. Before joining WhiteSource, Michael was a Product Manager at GE Digital, and he previously held a number of software development positions spanning over 10 years. Michael is currently leading WhiteSource for Developers, a suite of native developer integrations empowering developers to secure products faster without slowing down development. LinkedIn | Twitter