AlgoMaster Logo

Security Best Practices

Low Priority25 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Most security incidents in Python applications are not exotic exploits. They are mundane mistakes that compound: a query built with string concatenation, a pickle.loads on user input, a random.randint used for a password reset token, a secret committed to git. The fixes are usually one line. The hard part is knowing which line. This lesson covers the security pitfalls that show up most often in Python code, the standard-library and ecosystem tools that prevent them, and how to map common Python mistakes to the OWASP categories that describe them.

SQL Injection: Use Parameterized Queries

SQL injection happens when user input is concatenated directly into a SQL string. The classic example:

If the user enters priya@example.com, this works. If they enter ' OR '1'='1, the query becomes SELECT * FROM customers WHERE email = '' OR '1'='1', which returns every customer. Worse inputs ('; DROP TABLE customers; --) destroy data.

Fix: never build SQL by concatenation. Pass the values as parameters, and let the database driver escape them.

The ? placeholder (or %s in psycopg2, :name in some drivers) tells the driver "the next argument is a value, not SQL." The driver handles quoting, escaping, and type conversion. The placeholder syntax varies by driver:

LibraryPlaceholderExample
sqlite3?cursor.execute("... = ?", (value,))
psycopg2 (PostgreSQL)%scursor.execute("... = %s", (value,))
mysql-connector%scursor.execute("... = %s", (value,))
pyodbc?cursor.execute("... = ?", value)

Higher-level libraries (SQLAlchemy, Django ORM, Tortoise ORM) make this even safer because they build queries from objects, so injection is structurally impossible for normal use. The rare cases where you do need raw SQL inside an ORM still use parameterized queries, never f-strings.

This is a common Python security bug. The fix is mechanical: anywhere SQL meets user input, it goes through a placeholder.

The decision in the middle is the whole game. Concatenation produces SQL injection. Parameterization keeps the input as data, never as code.

Command Injection: Avoid shell=True

When a Python program runs a shell command, the same risk applies one layer up. If user input ends up inside a shell string, an attacker can inject extra commands.

If the user enters data.txt, this works. If they enter data.txt; rm -rf /, the shell runs both commands. The shell parses the string before executing it, so any shell metacharacter (;, &&, |, >, backticks) gives the attacker an entry point.

Fix 1: pass arguments as a list and skip the shell entirely. subprocess.run accepts a list of arguments where each item is one argv entry, no shell parsing.

The list form sends the arguments directly to execve. The filename is one argv element. Even if the user enters ; rm -rf /, it becomes a single (very weird) filename, not two commands.

Fix 2: if you absolutely must use a shell (because you need pipes, redirects, or shell features), quote the user input with shlex.quote.

shlex.quote wraps the value in single quotes and escapes any single quotes inside it, so the shell sees one literal argument. This is acceptable but easy to get wrong; prefer the list form whenever possible.

The rule of thumb: subprocess.run([...]) without shell=True is the default. Use shell=True only when you need shell features, and even then, never let raw user input touch the command string.

shell=True also makes commands slower (spinning up a shell), less portable (Windows shell differs from POSIX), and harder to debug (you get the shell's error messages, not the command's). The list form is faster, safer, and clearer.

Path Traversal: Validate Paths

When a program opens a file whose name comes from user input, an attacker can use .. to escape the intended directory.

If the user enters report.pdf, this reads /var/uploads/report.pdf. If they enter ../../etc/passwd, it reads /etc/passwd. The / operator doesn't reject .. parts, and Path won't either; the code has to validate.

Fix: resolve the candidate path, then check that it's still inside the intended directory.

resolve() collapses .. components and symlinks into an absolute path. is_relative_to (added in Python 3.9) checks whether the resolved path is still under UPLOADS. If not, the input was trying to escape.

For older Python versions (or extra defense in depth), compare path strings directly:

Don't forget the trailing slash, or /var/uploads-public would match /var/uploads.

A common related mistake is allowing absolute paths from input. If the user enters /etc/passwd, then UPLOADS / "/etc/passwd" resolves to /etc/passwd because / joined with an absolute path discards the left side. Validate that the input doesn't start with /, or use os.path.basename to keep only the filename.

Pickle and Untrusted Data

pickle.loads reconstructs Python objects from a byte stream. The format is powerful enough to execute arbitrary code during deserialization, by design. That makes pickle the wrong choice for any data that came from outside your program.

A malicious payload can contain a __reduce__ directive that runs os.system("rm -rf /") (or anything else) when pickle.loads is called. There's no sandbox. The Python docs say it directly: "Never unpickle data received from an untrusted or unauthenticated source."

Fix: for any data that came from a network, a user, or a file the user can edit, use a format that doesn't execute code:

NeedUse this
Human-readable, structured datajson
Compact binarymsgpack, protobuf, flatbuffers
Python literals onlyast.literal_eval for input strings
Schema validationpydantic, attrs, dataclasses with explicit construction

Pickle is fine for trusted, internal use: caching computed results to disk in a directory only your program writes to, sending objects between processes you control via multiprocessing. Anywhere the bytes could have come from someone else, switch to JSON or a schema-validated format.

The same warning applies to yaml.load (use yaml.safe_load instead), marshal, shelve, and any other "load a Python object from bytes" function.

eval and exec on User Input

From a security angle, eval on user input is remote code execution. Any input is a vector.

A user typing __import__('os').system('cat /etc/shadow') runs that. There is no safe way to sanitize the input; expression grammars are too rich to filter reliably.

Fix: the same options as the pickle case. If you need a number, parse a number. If you need a list, parse JSON. If you need a limited expression language (a search query, a math formula), build a real parser with a whitelist, or use a library like simpleeval that limits what's allowed.

ast.literal_eval only accepts literals. Any function call, attribute access, or name lookup raises ValueError. That's enough for most "let the user type a small data structure" use cases.

random for Tokens: Use secrets Instead

The random module is a pseudo-random number generator (Mersenne Twister by default) designed for simulations, games, and statistical sampling. It is not cryptographically secure. Given a few outputs, the internal state can be recovered, so tokens generated with random are predictable.

An attacker who sees a few previous tokens can compute the next one. For a password reset link, that means hijacking accounts.

Fix: use the secrets module (Python 3.6+). It draws from the operating system's cryptographic random source and is designed for tokens, passwords, and keys.

The secrets module also has secrets.compare_digest(a, b) for comparing tokens without leaking length information through timing. If you compare tokens with ==, the comparison short-circuits on the first mismatched byte, which an attacker can measure to recover the token byte by byte.

The rule is binary: any random value that an attacker shouldn't be able to guess (tokens, session IDs, password reset links, API keys, encryption nonces, salts) comes from secrets. Everything else (dice, shuffled playlists, simulation seeds) can use random.

Secrets in Code

A significant share of "security incidents" trace back to a single line: an API key, database password, or signing secret committed to a git repository.

Once committed, the secret is in the repository's history forever, even if a later commit removes it. Anyone with read access (CI logs, forks, leaked backups, public repositories) has it.

Fix: read secrets from environment variables, a secrets manager, or a config file outside source control.

os.environ["X"] raises KeyError if the variable is missing, which is the desired behavior; the program fails fast instead of running with empty credentials. For optional values, os.environ.get("X", default) is the pattern.

For local development, a .env file plus python-dotenv is the common shape:

The .env file goes in .gitignore and never gets committed. A .env.example with the variable names (but no values) tells future developers what to set.

For production, real secret managers (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler) replace the .env file. The Python code stays the same: read from an environment variable, the secret manager injects the value.

Two tools that catch this anti-pattern:

  • `detect-secrets` scans for accidental secrets in code; add it to a pre-commit hook.
  • GitHub secret scanning notifies you when a recognized secret is pushed, and can auto-revoke certain providers' keys.

If a secret does end up in git, rotate it immediately. Removing the commit doesn't help; assume it's compromised, generate a new one, and revoke the old one through the provider's console.

Dependency Vulnerabilities

Most Python projects depend on dozens of third-party packages, and a vulnerability in any of them is a vulnerability in your project. The 2017 Equifax breach happened because of an unpatched dependency. The fixes are well-understood, but they require running scanners regularly.

pip-audit is the PyPA's official tool; safety is the older third-party option. Both check the installed packages (or a requirements.txt) against the Python Packaging Advisory Database. A report looks like this:

The fix is to upgrade the package. In a CI pipeline, the scan runs on every commit and fails the build when vulnerabilities are found.

For long-term dependency hygiene:

  1. Pin versions in production. Use pip-tools to generate a fully resolved requirements.txt with exact versions. The same versions get installed everywhere; issues surface in development, not production.
  2. Use a dependency dashboard. GitHub's Dependabot, Renovate, or Snyk open pull requests when dependencies have updates, so you upgrade on a schedule instead of in a panic.
  3. Watch out for typosquatting. Packages with names like python-requests (instead of requests) or urlib3 (instead of urllib3) appear on PyPI from time to time and steal credentials. Copy package names from the official documentation, not from search results.
  4. Audit what you're installing. A package with three downloads a week and no recent commits is a higher risk than one with a million downloads and an active team. pypi-stats and the package's PyPI page tell you both.

HTTPS and TLS

Network calls in production should always go over HTTPS. The requests, httpx, and urllib3 libraries all do this correctly by default, and they verify TLS certificates by default. The mistake to avoid is disabling those checks.

verify=False accepts any certificate, including ones signed by an attacker. A network adversary can intercept the connection, present their own cert, and read or modify the response. The library prints a warning, which is easy to dismiss.

Fix: keep verify=True (the default). If the server uses a self-signed or internal CA, point verify at the CA bundle:

For client certificates (mutual TLS), pass the cert pair:

The same rule applies one layer down: when writing a server, configure TLS through a reverse proxy (nginx, Caddy) or a managed load balancer (AWS ALB, GCP Load Balancer). Terminating TLS in pure Python is possible (ssl.SSLContext) but easy to misconfigure; dedicated tools handle the cipher list, the key rotation, and the OCSP stapling.

Static Analysis: Bandit and Semgrep

Some security bugs can be found by reading the code. Tools that do this automatically catch a lot of low-hanging fruit before the code ships.

Bandit scans Python code for known security anti-patterns: eval, exec, weak crypto, hardcoded passwords, subprocess with shell=True, unsafe deserialization, and dozens more. It runs in seconds.

A typical report points at the file, line, severity, and rule:

Semgrep is a more general pattern-matching tool with a large library of Python security rules. It catches a broader set of bugs (SQL injection patterns, missing CSRF tokens, XSS in templates) and is easy to extend with custom rules.

Both tools fit into a pre-commit hook and a CI pipeline. They produce false positives, so the workflow is "scan, triage, fix the real ones, suppress the noise with explicit comments." For the ones you intentionally accept, add a # nosec or # nosemgrep comment with a reason.

OWASP Top 10 Mapping

The OWASP Top 10 is the industry's shorthand for the most common web application security risks. The 2021 list maps cleanly to the Python anti-patterns above.

OWASP categoryPython manifestationsFix
A01: Broken Access ControlMissing auth checks, path traversal, IDOR (insecure direct object reference)Validate authorization on every request; validate paths; never trust client-supplied IDs
A02: Cryptographic Failuresrandom for tokens, weak hashes (MD5, SHA-1), hardcoded keysUse secrets, use hashlib.sha256 or bcrypt/argon2 for passwords, load keys from environment
A03: InjectionSQL injection, command injection, NoSQL injection, LDAP injectionParameterized queries, list-form subprocess, escape user input at the boundary
A04: Insecure DesignMissing rate limits, no audit logging, trust boundaries unclearThreat model the design; assume input is hostile; log security-relevant events
A05: Security MisconfigurationDEBUG=True in prod, default credentials, overly permissive CORS, verify=FalseStrict prod config; remove defaults; least-privilege CORS; verify TLS
A06: Vulnerable ComponentsOutdated packages, dependency typosquattingpip-audit, Dependabot, pin versions, audit unknown packages
A07: Auth and Session FailuresPredictable session tokens, plaintext password storage, weak password policiessecrets.token_urlsafe, bcrypt/argon2, strong password requirements
A08: Software and Data Integrity Failurespickle.loads on untrusted data, missing package signatures, CI poisoningJSON / msgpack instead of pickle, sign artifacts, lock CI to specific commit SHAs
A09: Logging and Monitoring FailuresNo logs for auth attempts, no alerts on suspicious activityStructured logging, log auth events, alert on patterns (e.g., failed logins)
A10: SSRFA server that fetches a URL the user supplied, with no validationAllowlist hostnames, block private IP ranges, use a proxy that enforces both

Most of these are not framework-specific. Django, Flask, FastAPI, and pure-stdlib programs all hit the same set of risks. The frameworks each have docs sections on hardening (Django's manage.py check --deploy, FastAPI's OAuth2PasswordBearer); reading them is part of shipping the framework.

Three OWASP items shown in code:

The pattern across all three is the same: validate at the boundary, use the standard library or a vetted package for the cryptographic part, fail closed when the input doesn't fit the rules.

Adding security checks slows individual requests by a few microseconds to a few milliseconds. The cost of the missing check is an incident response, not a slow endpoint. Security work pays off in disasters that didn't happen.

A Security Workflow

Security isn't a one-time pass over the code; it's a workflow that runs continuously.

The loop in the diagram is the practical version of "security-as-process." Static analysis catches the easier issues, dependency audits catch the high-impact transitive bugs, code review catches design-level issues, tests confirm specific behaviors, and monitoring catches the things that slipped through. When an incident happens, rotate and patch, then feed the lesson back into the linter rules and the code review checklist.

Quiz

Security Best Practices Quiz

10 quizzes