Secure Python Coding

Most real-world Python security bugs come from trusting untrusted input — this topic surveys the main attack surfaces and the standard-library-first defenses against them.

Secure Python coding is less about exotic language attacks and more about disciplined handling of untrusted input: data that came from outside your program's trust boundary (a network request, a file upload, a command-line argument, an environment variable, another process's output). This topic surveys the most common categories of Python-specific security mistakes and the standard-library-first patterns that avoid them; it is a survey, not a full security-engineering course — see the related Input Validation and Safe File & Process Handling topics for the detail on those specific mechanics.

The Trust Boundary: Untrusted Input

Any data your program did not itself generate should be treated as untrusted until validated: HTTP request bodies and headers, form fields, file contents, filenames, command-line arguments, environment variables, and data read from a database that another process writes to. The security mindset is to identify exactly where untrusted data enters your program (the trust boundary) and validate or sanitize it there, rather than trusting it implicitly deeper in the call stack where the origin has been forgotten.

'Untrusted' is about origin, not format — even a value that looks like a plain integer or filename must be validated if an attacker controls it, because the string "../../etc/passwd" or "1; rm -rf /" also looks like plausible input until it's interpreted.

Injection: The General Pattern

Injection vulnerabilities share one root cause: untrusted data is concatenated into a string that is then interpreted as code or a command by something downstream (a SQL engine, a shell, an eval/exec call, an HTML template). The fix is almost always the same shape: keep data and code/command structure separate, using an API that accepts data as data rather than building an executable string by hand.

A parameterized query sends the SQL text and the value as separate pieces to the database driver, so a value like "' OR '1'='1" is treated as literal data, never as SQL syntax.

import sqlite3

conn = sqlite3.connect("app.db")
cursor = conn.cursor()

username = get_untrusted_input()  # e.g. from a web form

# VULNERABLE: string formatting builds SQL from untrusted data
# cursor.execute(f"SELECT * FROM users WHERE name = '{username}'")

# SAFE: parameterized query -- the driver keeps data separate from SQL structure
cursor.execute("SELECT * FROM users WHERE name = ?", (username,))
rows = cursor.fetchall()

Path Traversal

Path traversal happens when a program builds a filesystem path by joining a trusted base directory with an untrusted filename that contains .. segments (or is itself an absolute path), letting an attacker escape the intended directory. pathlib makes the defense straightforward: resolve the joined path and verify it is still inside the intended base directory before using it.

.resolve() collapses .. segments and symlinks; .is_relative_to() (3.9+) then confirms the fully resolved path is still inside the trusted base directory before any file operation touches it.

from pathlib import Path

def safe_read(base_dir: Path, user_filename: str) -> str:
    base = base_dir.resolve()
    candidate = (base / user_filename).resolve()
    if not candidate.is_relative_to(base):  # Python 3.9+
        raise ValueError(f"invalid path: {user_filename!r}")
    return candidate.read_text(encoding="utf-8")

# safe_read(Path("uploads"), "report.txt")       -> reads uploads/report.txt
# safe_read(Path("uploads"), "../../etc/passwd") -> raises ValueError

Command Injection

Command injection occurs when untrusted data reaches a shell that interprets metacharacters (;, |, &&, backticks) as command separators. The standard-library fix is to invoke subprocess with an argument list and shell=False (the default), never by formatting a string and passing shell=True; this is covered in depth in the Safe File & Process Handling topic, and summarized here as one of the core injection categories.

import subprocess

filename = get_untrusted_input()  # e.g. "report.txt; rm -rf ~"

# VULNERABLE: shell=True interprets ";" as a command separator
# subprocess.run(f"cat {filename}", shell=True)

# SAFE: argument list, no shell interpretation of metacharacters
subprocess.run(["cat", filename], check=True)

Unsafe Deserialization

pickle.load() and pickle.loads() can execute arbitrary code during deserialization — a crafted pickle stream can call arbitrary callables as part of reconstructing an object. Never unpickle data from an untrusted or unauthenticated source. For untrusted data, use a format with no code-execution surface, most commonly json (built into the standard library) for structured data.

eval() and exec() on untrusted strings are a related, even more direct code-execution risk: eval(user_input) executes arbitrary Python. Avoid them on untrusted data entirely; if you need to evaluate a restricted arithmetic-like expression, use ast.literal_eval(), which only parses Python literals (numbers, strings, lists, dicts, tuples, booleans, None) and refuses anything else.

import json
import pickle

# VULNERABLE: unpickling attacker-controlled bytes can run arbitrary code
# data = pickle.loads(untrusted_bytes)

# SAFE: json.loads only ever produces plain data (dict/list/str/int/float/bool/None)
data = json.loads(untrusted_bytes)
import ast

ast.literal_eval("[1, 2, 3]")        # [1, 2, 3] -- safe
ast.literal_eval("{'a': 1}")         # {'a': 1} -- safe
ast.literal_eval("__import__('os')") # raises ValueError -- not a literal

Secrets Management

Never hardcode API keys, passwords, or tokens directly in source code — they end up in version control history permanently, even if later removed. Load secrets from environment variables (os.environ) or a dedicated secrets manager, and keep any .env-style file listed in .gitignore. For comparing secret values (e.g. a webhook signature or API token) use a constant-time comparison, hmac.compare_digest(), rather than ==, so timing differences in the comparison can't be used to guess the secret byte-by-byte.

hmac.compare_digest always takes the same amount of time regardless of where the first mismatching byte occurs, unlike == on strings, which can (in principle) short-circuit and leak timing information.

import hmac
import os

expected_token = os.environ["API_TOKEN"]  # loaded from environment, not hardcoded

def is_valid_token(provided: str) -> bool:
    return hmac.compare_digest(provided, expected_token)

Dependency Security

Third-party packages are part of your program's trust boundary too: a compromised or vulnerable dependency runs with the same privileges as your own code. Practical hygiene: pin dependency versions (a lockfile, not just loose ranges) so you know exactly what's installed, check installed packages against known-vulnerability databases (the third-party tool pip-audit is commonly used for this — not part of the standard library), and avoid installing packages from unverified sources. Regularly updating dependencies trades a small amount of churn for closing known vulnerability windows.

Least Privilege

The principle of least privilege means a program (and the user/process account running it) should have only the permissions it actually needs, no more. In Python terms: don't run a script as root/administrator unless it genuinely needs elevated access; open files with the narrowest mode that suffices (read-only where you never write); scope database credentials to only the tables/operations a given service actually touches; and set restrictive filesystem permissions on files containing secrets (e.g. 0o600, owner-read/write only) rather than leaving default, broader permissions in place.

import os
from pathlib import Path

secret_file = Path("credentials.json")
secret_file.write_text('{"token": "..."}', encoding="utf-8")
os.chmod(secret_file, 0o600)  # owner read/write only -- no group/other access

Practical Usage

In practice, secure coding is applied at every point untrusted data crosses into a sensitive operation: validate/parse it (see Input Validation), never string-format it into SQL, shell commands, or eval, never unpickle it, and keep the credentials your code needs scoped as narrowly as possible. These habits cost little once internalized and close the overwhelming majority of real-world Python security incidents, which tend to be mundane injection or trust mistakes rather than exotic exploits.

Edge Cases

Validation performed only on the client side (e.g. in a web frontend) is not security — a malicious client can bypass it entirely and send arbitrary data straight to your server, so untrusted-input handling must happen on the server/trust-boundary side regardless of what a client claims to have already checked. Similarly, an allowlist (explicitly permitted values) is generally safer than a denylist (explicitly forbidden patterns) for validating untrusted input, because a denylist must anticipate every dangerous variant, while an allowlist only needs to describe what's actually valid.

Complete Example

import hmac
import json
import os
import sqlite3
from pathlib import Path

UPLOAD_DIR = Path("uploads").resolve()
API_TOKEN = os.environ["API_TOKEN"]

def authenticate(provided_token: str) -> bool:
    return hmac.compare_digest(provided_token, API_TOKEN)

def load_config(untrusted_json_text: str) -> dict:
    # json, not pickle: no code execution surface for untrusted bytes
    return json.loads(untrusted_json_text)

def read_uploaded_file(filename: str) -> str:
    candidate = (UPLOAD_DIR / filename).resolve()
    if not candidate.is_relative_to(UPLOAD_DIR):
        raise ValueError(f"invalid filename: {filename!r}")
    return candidate.read_text(encoding="utf-8")

def find_user(conn: sqlite3.Connection, username: str) -> sqlite3.Row | None:
    cursor = conn.execute("SELECT * FROM users WHERE name = ?", (username,))
    return cursor.fetchone()

Mental Model

Picture your program as a building with one clearly marked entrance (the trust boundary) where every visitor is checked. Once someone is past that checkpoint, the rest of the building assumes they belong there. Secure coding is making sure nothing gets past the checkpoint unchecked, and that the checkpoint isn't quietly bypassable through a side door (an alternate code path that skips validation).

Quick Reference

Injection (SQL/shell)   -> parameterized queries; subprocess arg lists, shell=False
Path traversal          -> resolve() + is_relative_to(base)
Deserialization         -> json, never pickle.loads on untrusted bytes
eval/exec on input       -> avoid; ast.literal_eval for literals only
Secrets                 -> environment variables, not hardcoded; hmac.compare_digest
Dependencies            -> pin versions, audit for known CVEs
Least privilege         -> narrowest file mode / DB permissions / process privilege

Key Takeaways

Treat any data your program didn't itself generate as untrusted, and validate it at the point it crosses the trust boundary.

Injection (SQL, shell, eval) is prevented by keeping data separate from code/command structure — parameterized queries and argument lists, not string concatenation.

Prevent path traversal by resolving joined paths and confirming they stay inside the intended base directory.

Never unpickle untrusted data; pickle can execute arbitrary code, json cannot.

Keep secrets out of source code, load them from the environment, and compare them with hmac.compare_digest, not ==.

Apply least privilege everywhere: file permissions, process privileges, and scoped credentials.

cursor.execute(f"SELECT * FROM users WHERE name = '{username}'") cursor.execute("SELECT * FROM users WHERE name = ?", (username,))

String-formatting untrusted data directly into SQL lets an attacker supply a value like ' OR '1'='1 to change the query's meaning (SQL injection). A parameterized query keeps the value out of the SQL text entirely.

import pickle config = pickle.loads(request.body) # request.body is attacker-controlled import json config = json.loads(request.body)

Unpickling untrusted bytes can execute arbitrary code as a side effect of object reconstruction. JSON has no such code-execution mechanism, so it's the safe choice for untrusted structured data.

import subprocess subprocess.run(f"grep {pattern} log.txt", shell=True) import subprocess subprocess.run(["grep", pattern, "log.txt"])

shell=True with an interpolated string lets shell metacharacters in pattern (;, |, backticks) run additional commands. An argument list with shell=False (the default) passes pattern as a single literal argument, never interpreted by a shell.

API_TOKEN = "sk_live_abc123..." # hardcoded directly in source import os API_TOKEN = os.environ["API_TOKEN"] # loaded from environment, not committed

A hardcoded secret is captured permanently in version-control history the moment it's committed, even if a later commit removes it. Loading it from the environment (or a secrets manager) keeps it out of the codebase entirely.

Why is cursor.execute("... WHERE name = ?", (username,)) safe from SQL injection while an f-string-built query is not?

The parameterized form sends the SQL text and the value as two separate pieces to the database driver; the driver never re-parses the value as SQL syntax. An f-string builds a single string where attacker-controlled text becomes part of the SQL grammar itself.

What makes pickle.loads() dangerous on untrusted data, and what's the standard-library alternative for untrusted structured data?

Pickle's deserialization protocol can invoke arbitrary callables while reconstructing an object, so a crafted pickle stream can execute arbitrary code just by being loaded. json (module in the standard library) only ever produces plain data types (dict, list, str, int, float, bool, None) with no code-execution mechanism.

Why should an allowlist generally be preferred over a denylist when validating untrusted input?

A denylist must anticipate every dangerous variant of a pattern (and attackers are creative at finding ones it missed), while an allowlist only needs to describe what is actually valid — anything not explicitly permitted is rejected by default, which fails safe.

Why is hmac.compare_digest() preferred over == for comparing a provided token to an expected secret?

== on strings can return as soon as it finds a differing character, making its execution time leak information about how many leading characters matched — a timing side-channel an attacker can exploit to guess the secret byte by byte. compare_digest takes constant time regardless of where a mismatch occurs.

A web form's JavaScript validates that an email field is non-empty before submitting. Is that sufficient input validation? Why or why not?

No — client-side validation is a UX convenience, not a security control, because a malicious client can bypass the JavaScript entirely (e.g. send a raw HTTP request) and submit arbitrary data straight to the server. The server must independently validate any data it receives, regardless of what the client claims to have checked.

What is the principle of least privilege, and give one concrete Python example of applying it.

It means a program or process should hold only the permissions it actually needs, nothing broader. Example: setting os.chmod(secret_file, 0o600) so a file containing credentials is readable/writable only by its owner, instead of leaving default, broader permissions in place.

Why is 'my dependencies are third-party code I don't control' a security-relevant fact rather than just a design detail?

A dependency runs with the same privileges as your own code, so a compromised or vulnerable package is effectively a hole in your own trust boundary. This is why pinning versions and auditing installed packages against known-vulnerability databases is treated as part of secure coding, not just packaging hygiene.

What security vulnerability does this snippet have, and what input would exploit it?

It is vulnerable to command injection: shell=True runs the interpolated string through a shell, which interprets metacharacters like ;. Input such as report.txt; rm -rf ~ would first cat the file, then run rm -rf ~ as a second command in the same shell invocation. The fix is subprocess.run(["cat", filename]) — an argument list with no shell interpretation.

Write a function safe_join(base_dir: Path, user_path: str) -> Path that returns the resolved path if user_path (joined onto base_dir) stays within base_dir, and raises ValueError otherwise (defending against path traversal).

from pathlib import Path def safe_join(base_dir: Path, user_path: str) -> Path: base = base_dir.resolve() candidate = (base / user_path).resolve() if not candidate.is_relative_to(base): raise ValueError(f"path escapes base directory: {user_path!r}") return candidate

Resolving both the base and the joined candidate path collapses .. segments and symlinks to their real, absolute form. is_relative_to() (Python 3.9+) then checks that the fully resolved candidate is still nested inside the resolved base — rejecting inputs like "../../etc/passwd" that would otherwise escape the intended directory.

Write a function parse_trusted_literal(text: str) that safely evaluates a string containing only Python literal syntax (like "[1, 2, 3]" or "{'a': 1}") without exposing the arbitrary-code-execution risk of eval(). It should return the parsed value, or re-raise a clear error for anything that isn't a valid literal.

import ast def parse_trusted_literal(text: str): try: return ast.literal_eval(text) except (ValueError, SyntaxError) as exc: raise ValueError(f"not a valid Python literal: {text!r}") from exc # parse_trusted_literal("[1, 2, 3]") -> [1, 2, 3] # parse_trusted_literal("__import__('os')") -> raises ValueError

ast.literal_eval parses the string as a Python expression but only accepts a fixed set of literal node types (numbers, strings, tuples, lists, dicts, sets, booleans, None) and rejects anything else — including function calls like __import__(...) — so it cannot be used to execute arbitrary code the way eval() can.

import subprocess
filename = input("File to view: ")
subprocess.run(f"cat {filename}", shell=True)