Python makes several unsafe things easy, and a few of them look completely innocent. This guide covers the mistakes that cause real vulnerabilities — SQL injection, unpickling untrusted data, shell commands, weak randomness, path traversal and vulnerable dependencies — each with the fix.
1. SQL injection#
# Dangerous
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
cursor.execute("SELECT * FROM users WHERE email = '%s'" % email)
An email of ' OR '1'='1 returns every row. Use placeholders, which send the query and the data separately:
cursor.execute("SELECT * FROM users WHERE email = ?", (email,)) # sqlite3
cursor.execute("SELECT * FROM users WHERE email = %s", (email,)) # psycopg, mysql
The trailing comma matters — (email) is just a string in brackets, while (email,) is a one-element tuple.
With SQLAlchemy, use bound parameters rather than string building:
from sqlalchemy import text
session.execute(
text("SELECT * FROM users WHERE email = :email"),
{"email": email},
)
2. pickle#
import pickle
data = pickle.loads(request.data) # remote code execution
Pickle is not a data format — it is a small program that reconstructs objects, and it can be made to call arbitrary functions during loading. Loading a pickle from an untrusted source is equivalent to running whatever code the sender chose.
import json
data = json.loads(request.data) # safe - data only
The same applies to yaml.load, which can construct arbitrary Python objects:
import yaml
config = yaml.load(text) # unsafe
config = yaml.safe_load(text) # safe
And to any model or array file that uses pickle underneath — joblib files, older NumPy saves with allow_pickle=True, and some machine learning checkpoints. Treat a downloaded model file with the same caution as a downloaded executable.
3. Running shell commands#
import subprocess
# Dangerous - a shell interprets the whole string
subprocess.run(f"convert {filename} out.png", shell=True)
A filename of a.png; rm -rf / runs both commands. Pass a list instead, which skips the shell entirely:
subprocess.run(["convert", filename, "out.png"], check=True)
Arguments are passed directly to the program, so shell metacharacters have no special meaning. Add check=True so a failure raises rather than being ignored.
os.system() always uses a shell and has no safe form. Replace it with subprocess.run.
4. Randomness#
import random
token = "".join(random.choices(string.ascii_letters, k=32)) # predictable
The random module uses a deterministic generator. Given enough output, its future values can be predicted — fine for shuffling a deck, unsafe for anything granting access.
import secrets
token = secrets.token_urlsafe(32)
hex_token = secrets.token_hex(16)
password = "".join(secrets.choice(alphabet) for _ in range(16))
Use secrets for session identifiers, password reset tokens, API keys and one-time codes. It also provides compare_digest, which compares in constant time:
if secrets.compare_digest(provided_token, expected_token):
...
5. Passwords#
import hashlib
hashed = hashlib.md5(password.encode()).hexdigest() # never
hashed = hashlib.sha256(password.encode()).hexdigest() # also never
Both are designed to be fast, which is exactly wrong here. Use an algorithm designed to be slow:
python -m pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
hasher = PasswordHasher()
stored = hasher.hash(password) # salt included in the output
try:
hasher.verify(stored, attempt)
except VerifyMismatchError:
pass # wrong password
bcrypt is an equally acceptable choice. Whichever you use, size the database column generously — a truncated hash fails verification for every user and the cause is not obvious from the symptom.
6. Path traversal#
from pathlib import Path
# Dangerous
content = (Path("uploads") / request.args["name"]).read_text()
A name of ../../.env escapes the folder. Resolve and verify:
base = Path("uploads").resolve()
target = (base / Path(name).name).resolve()
if not target.is_relative_to(base):
abort(404)
content = target.read_text()
Two defences stacked: Path(name).name strips any directory part, and is_relative_to confirms the resolved path is still inside the base folder.
7. Secrets#
import os
# Never in source
API_KEY = "sk-1234567890abcdef"
# From the environment
API_KEY = os.environ["API_KEY"] # required, fails loudly
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
Using square brackets for required values means the program fails at start-up with a clear message rather than making a request with an empty key and getting a confusing 401.
Once a secret is in git history, rotating it is the only real fix. Add .env to .gitignore from the first commit, and consider a pre-commit hook that scans for key-shaped strings.
8. Dependencies#
python -m pip install pip-audit
pip-audit
# Or
python -m pip install safety
safety check
Run one of these in your build pipeline so a new advisory fails the build rather than waiting to be noticed. Pin versions in requirements.txt so installs are reproducible, and check package names carefully — typosquatting on PyPI is a real and recurring attack.
9. Deserialising and parsing other formats#
import xml.etree.ElementTree as ET
tree = ET.parse(untrusted_file) # vulnerable to entity attacks
python -m pip install defusedxml
from defusedxml.ElementTree import parse
tree = parse(untrusted_file) # entity expansion disabled
The standard library XML parsers are vulnerable to entity expansion attacks that can consume all available memory or read local files. The defusedxml package is a drop-in replacement.
10. eval and exec#
result = eval(user_input) # arbitrary code execution
exec(user_input) # the same
There is no safe way to sandbox these. Restricting the globals dictionary does not work — the escapes are well documented. For evaluating arithmetic, use ast.literal_eval, which only handles literals:
import ast
value = ast.literal_eval("[1, 2, {'a': 3}]") # safe
value = ast.literal_eval("__import__('os')") # raises ValueError
A checklist#
- Every query uses placeholders, including in admin scripts
- No
pickle.loadoryaml.loadon data you did not create - No
shell=Truewith any user-supplied content - Tokens from
secrets, neverrandom - Passwords hashed with Argon2 or bcrypt
- File paths resolved and checked against a base directory
- Secrets from the environment,
.envin.gitignore - A dependency audit in the build
- Debug mode off in production
Questions people ask#
Is Python less secure than other languages?
No, but it makes several unsafe things convenient — pickle, eval, shell=True. The vulnerabilities come from convenience, not from the language design.
Is pickle ever acceptable?
Yes, for data you created and control, staying within your own system — a local cache, for instance. Never across a network boundary or from a download.
Do I need a framework for security?
Django and Flask with sensible extensions give you CSRF protection, escaping in templates and secure session handling by default. Writing that yourself means getting every detail right.
How do I keep dependencies current?
Pin versions, run an audit in your build, and let Dependabot or Renovate open the update pull requests. Being years behind is far harder to secure than staying close to current.
Where to go next#
- Python best practices — habits that prevent several of these.
- Python virtual environments — dependency isolation.
- PHP security best practices — the same ideas server-side.